Membership subscriptions ()

Enter: Odysee Premium.
This commit is contained in:
mayeaux 2022-03-09 19:05:37 +01:00 committed by GitHub
parent a34e07e970
commit fb3a73d8a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 3032 additions and 347 deletions
extras/lbryinc/redux/selectors
flow-typed
ui
component
channelContent
channelSelector
channelThumbnail
claimList
claimListDiscover
claimPreview
claimPreviewSubtitle
claimPreviewTile
claimTilesDiscover
comment
commentsList
common
headerMenuButtons
headerProfileMenuButton
livestreamChatLayout
livestreamComment
membershipSplash
publishForm
recommendedContent
router
sideNavigation
textareaSuggestionsItem
uriIndicator
wunderbarSuggestion
constants
effects
modal
modalConfirmOdyseeMembership
modalMembershipSplash
modalRouter
page
redux
scss
web/component/ads

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { selectClaimIdForUri } from 'redux/selectors/claims'; import { selectClaimIdForUri } from 'redux/selectors/claims';
type State = { claims: any, stats: any }; type State = { claims: any, stats: any, user: User };
const selectState = (state: State) => state.stats || {}; const selectState = (state: State) => state.stats || {};
export const selectViewCount = (state: State) => selectState(state).viewCountById; export const selectViewCount = (state: State) => selectState(state).viewCountById;

1
flow-typed/user.js vendored
View file

@ -32,4 +32,5 @@ declare type User = {
odysee_live_enabled: boolean, odysee_live_enabled: boolean,
odysee_live_disabled: boolean, odysee_live_disabled: boolean,
global_mod: boolean, global_mod: boolean,
odyseeMembershipsPerClaimIds: ?{},
}; };

View file

@ -11,7 +11,7 @@ import { doResolveUris } from 'redux/actions/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings'; import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings';
import { doFetchChannelLiveStatus } from 'redux/actions/livestream'; import { doFetchChannelLiveStatus } from 'redux/actions/livestream';
import { selectActiveLivestreamForChannel, selectActiveLivestreamInitialized } from 'redux/selectors/livestream'; import { selectActiveLivestreamForChannel, selectActiveLivestreamInitialized } from 'redux/selectors/livestream';
@ -32,11 +32,11 @@ const select = (state, props) => {
channelIsMine: selectClaimIsMine(state, claim), channelIsMine: selectClaimIsMine(state, claim),
channelIsBlocked: makeSelectChannelIsMuted(props.uri)(state), channelIsBlocked: makeSelectChannelIsMuted(props.uri)(state),
claim, claim,
isAuthenticated: selectUserVerifiedEmail(state),
showMature: selectShowMatureContent(state), showMature: selectShowMatureContent(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT), tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId), activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
activeLivestreamInitialized: selectActiveLivestreamInitialized(state), activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}; };
}; };

View file

@ -39,6 +39,7 @@ type Props = {
doFetchChannelLiveStatus: (string) => void, doFetchChannelLiveStatus: (string) => void,
activeLivestreamForChannel: any, activeLivestreamForChannel: any,
activeLivestreamInitialized: boolean, activeLivestreamInitialized: boolean,
userHasPremiumPlus: boolean,
}; };
function ChannelContent(props: Props) { function ChannelContent(props: Props) {
@ -49,7 +50,6 @@ function ChannelContent(props: Props) {
channelIsBlocked, channelIsBlocked,
channelIsBlackListed, channelIsBlackListed,
claim, claim,
isAuthenticated,
defaultPageSize = CS.PAGE_SIZE, defaultPageSize = CS.PAGE_SIZE,
defaultInfiniteScroll = true, defaultInfiniteScroll = true,
showMature, showMature,
@ -61,8 +61,10 @@ function ChannelContent(props: Props) {
doFetchChannelLiveStatus, doFetchChannelLiveStatus,
activeLivestreamForChannel, activeLivestreamForChannel,
activeLivestreamInitialized, activeLivestreamInitialized,
userHasPremiumPlus,
} = props; } = props;
// const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; // const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
const claimsInChannel = 9999; const claimsInChannel = 9999;
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [isSearching, setIsSearching] = React.useState(false); const [isSearching, setIsSearching] = React.useState(false);
@ -160,7 +162,7 @@ function ChannelContent(props: Props) {
defaultOrderBy={CS.ORDER_BY_NEW} defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize} pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll} infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && { node: <Ads type="video" tileLayout={tileLayout} small /> }} injectedItem={SHOW_ADS && !userHasPremiumPlus && { node: <Ads type="video" tileLayout={tileLayout} small /> }}
meta={ meta={
showFilters && ( showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline"> <Form onSubmit={() => {}} className="wunderbar--inline">

View file

@ -1,16 +1,24 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectMyChannelClaims } from 'redux/selectors/claims'; import { selectMyChannelClaims, selectClaimsByUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app'; import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doSetActiveChannel, doSetIncognito } from 'redux/actions/app'; import { doSetActiveChannel, doSetIncognito } from 'redux/actions/app';
import { doFetchUserMemberships } from 'redux/actions/user';
import ChannelSelector from './view'; import ChannelSelector from './view';
const select = (state) => ({ const select = (state, props) => {
channels: selectMyChannelClaims(state), const activeChannelClaim = selectActiveChannelClaim(state);
activeChannelClaim: selectActiveChannelClaim(state),
incognito: selectIncognito(state), return {
}); channels: selectMyChannelClaims(state),
activeChannelClaim,
incognito: selectIncognito(state),
odyseeMembershipByUri: (uri) => selectOdyseeMembershipForUri(state, uri),
claimsByUri: selectClaimsByUri(state),
};
};
export default connect(select, { export default connect(select, {
doSetActiveChannel, doSetActiveChannel,
doSetIncognito, doSetIncognito,
doFetchUserMemberships,
})(ChannelSelector); })(ChannelSelector);

View file

@ -8,6 +8,8 @@ import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import ChannelTitle from 'component/channelTitle'; import ChannelTitle from 'component/channelTitle';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
selectedChannelUrl: string, // currently selected channel selectedChannelUrl: string, // currently selected channel
@ -18,20 +20,32 @@ type Props = {
doSetActiveChannel: (string) => void, doSetActiveChannel: (string) => void,
incognito: boolean, incognito: boolean,
doSetIncognito: (boolean) => void, doSetIncognito: (boolean) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
odyseeMembershipByUri: (uri: string) => string,
}; };
type ListItemProps = { type ListItemProps = {
uri: string, uri: string,
isSelected?: boolean, isSelected?: boolean,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
odyseeMembershipByUri: (uri: string) => string,
}; };
function ChannelListItem(props: ListItemProps) { function ChannelListItem(props: ListItemProps) {
const { uri, isSelected = false } = props; const { uri, isSelected = false, claimsByUri, doFetchUserMemberships, odyseeMembershipByUri } = props;
const membership = odyseeMembershipByUri(uri);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, [uri], claimsByUri, doFetchUserMemberships, [uri]);
return ( return (
<div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}> <div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}>
<ChannelThumbnail uri={uri} hideStakedIndicator xsmall noLazyLoad /> <ChannelThumbnail uri={uri} hideStakedIndicator xsmall noLazyLoad />
<ChannelTitle uri={uri} /> <ChannelTitle uri={uri} />
<PremiumBadge membership={membership} />
{isSelected && <Icon icon={ICONS.DOWN} />} {isSelected && <Icon icon={ICONS.DOWN} />}
</div> </div>
); );
@ -52,11 +66,23 @@ function IncognitoSelector(props: IncognitoSelectorProps) {
} }
function ChannelSelector(props: Props) { function ChannelSelector(props: Props) {
const { channels, activeChannelClaim, doSetActiveChannel, hideAnon = false, incognito, doSetIncognito } = props; const {
channels,
activeChannelClaim,
doSetActiveChannel,
hideAnon = false,
incognito,
doSetIncognito,
odyseeMembershipByUri,
claimsByUri,
doFetchUserMemberships,
} = props;
const { const {
push, push,
location: { pathname }, location: { pathname },
} = useHistory(); } = useHistory();
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url; const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
function handleChannelSelect(channelClaim) { function handleChannelSelect(channelClaim) {
@ -71,14 +97,26 @@ function ChannelSelector(props: Props) {
{(incognito && !hideAnon) || !activeChannelUrl ? ( {(incognito && !hideAnon) || !activeChannelUrl ? (
<IncognitoSelector isSelected /> <IncognitoSelector isSelected />
) : ( ) : (
<ChannelListItem uri={activeChannelUrl} isSelected /> <ChannelListItem
odyseeMembershipByUri={odyseeMembershipByUri}
uri={activeChannelUrl}
isSelected
claimsByUri={claimsByUri}
doFetchUserMemberships={doFetchUserMemberships}
/>
)} )}
</MenuButton> </MenuButton>
<MenuList className="menu__list channel__list"> <MenuList className="menu__list channel__list">
{channels && {channels &&
channels.map((channel) => ( channels.map((channel) => (
<MenuItem key={channel.permanent_url} onSelect={() => handleChannelSelect(channel)}> <MenuItem key={channel.permanent_url} onSelect={() => handleChannelSelect(channel)}>
<ChannelListItem uri={channel.permanent_url} /> <ChannelListItem
odyseeMembershipByUri={odyseeMembershipByUri}
uri={channel.permanent_url}
claimsByUri={claimsByUri}
doFetchUserMemberships={doFetchUserMemberships}
/>
</MenuItem> </MenuItem>
))} ))}
{!hideAnon && ( {!hideAnon && (

View file

@ -1,14 +1,24 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectThumbnailForUri, selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims'; import {
selectThumbnailForUri,
selectClaimForUri,
selectIsUriResolving,
selectClaimsByUri,
selectOdyseeMembershipForUri,
} from 'redux/selectors/claims';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import ChannelThumbnail from './view'; import ChannelThumbnail from './view';
const select = (state, props) => ({ const select = (state, props) => ({
thumbnail: selectThumbnailForUri(state, props.uri), thumbnail: selectThumbnailForUri(state, props.uri),
claim: selectClaimForUri(state, props.uri), claim: selectClaimForUri(state, props.uri),
isResolving: selectIsUriResolving(state, props.uri), isResolving: selectIsUriResolving(state, props.uri),
odyseeMembership: selectOdyseeMembershipForUri(state, props.uri),
claimsByUri: selectClaimsByUri(state),
}); });
export default connect(select, { export default connect(select, {
doResolveUri, doResolveUri,
doFetchUserMemberships,
})(ChannelThumbnail); })(ChannelThumbnail);

View file

@ -4,9 +4,10 @@ import { parseURI } from 'util/lbryURI';
import classnames from 'classnames'; import classnames from 'classnames';
import Gerbil from './gerbil.png'; import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper'; import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
import ChannelStakedIndicator from 'component/channelStakedIndicator';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import { AVATAR_DEFAULT } from 'config'; import { AVATAR_DEFAULT } from 'config';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
thumbnail: ?string, thumbnail: ?string,
@ -26,6 +27,12 @@ type Props = {
noOptimization?: boolean, noOptimization?: boolean,
setThumbUploadError: (boolean) => void, setThumbUploadError: (boolean) => void,
ThumbUploadError: boolean, ThumbUploadError: boolean,
claimsByUri: { [string]: any },
odyseeMembership: string,
doFetchUserMemberships: (claimIdCsv: string) => void,
showMemberBadge?: boolean,
isChannel?: boolean,
checkMembership: boolean,
}; };
function ChannelThumbnail(props: Props) { function ChannelThumbnail(props: Props) {
@ -42,10 +49,15 @@ function ChannelThumbnail(props: Props) {
doResolveUri, doResolveUri,
isResolving, isResolving,
noLazyLoad, noLazyLoad,
hideStakedIndicator = false,
hideTooltip, hideTooltip,
setThumbUploadError, setThumbUploadError,
ThumbUploadError, ThumbUploadError,
claimsByUri,
odyseeMembership,
doFetchUserMemberships,
showMemberBadge,
isChannel,
checkMembership = true,
} = props; } = props;
const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError); const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError);
const shouldResolve = !isResolving && claim === undefined; const shouldResolve = !isResolving && claim === undefined;
@ -56,6 +68,16 @@ function ChannelThumbnail(props: Props) {
const isGif = channelThumbnail && channelThumbnail.endsWith('gif'); const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; const showThumb = (!obscure && !!thumbnail) || thumbnailPreview;
const badgeProps = {
membership: odyseeMembership,
linkPage: isChannel,
placement: isChannel ? 'bottom' : undefined,
hideTooltip,
className: isChannel ? 'profile-badge__tooltip' : undefined,
};
useGetUserMemberships(checkMembership, [uri], claimsByUri, doFetchUserMemberships, [uri]);
// Generate a random color class based on the first letter of the channel name // Generate a random color class based on the first letter of the channel name
const { channelName } = parseURI(uri); const { channelName } = parseURI(uri);
let initializer; let initializer;
@ -76,7 +98,7 @@ function ChannelThumbnail(props: Props) {
if (isGif && !allowGifs) { if (isGif && !allowGifs) {
return ( return (
<FreezeframeWrapper src={channelThumbnail} className={classnames('channel-thumbnail', className)}> <FreezeframeWrapper src={channelThumbnail} className={classnames('channel-thumbnail', className)}>
{!hideStakedIndicator && <ChannelStakedIndicator uri={uri} claim={claim} hideTooltip={hideTooltip} />} {showMemberBadge && <PremiumBadge {...badgeProps} />}
</FreezeframeWrapper> </FreezeframeWrapper>
); );
} }
@ -103,7 +125,7 @@ function ChannelThumbnail(props: Props) {
} }
}} }}
/> />
{!hideStakedIndicator && <ChannelStakedIndicator uri={uri} claim={claim} hideTooltip={hideTooltip} />} {showMemberBadge && <PremiumBadge {...badgeProps} />}
</div> </div>
); );
} }

View file

@ -58,6 +58,7 @@ type Props = {
showEdit?: boolean, showEdit?: boolean,
droppableProvided?: any, droppableProvided?: any,
unavailableUris?: Array<string>, unavailableUris?: Array<string>,
showMemberBadge?: boolean,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -96,6 +97,7 @@ export default function ClaimList(props: Props) {
showEdit, showEdit,
droppableProvided, droppableProvided,
unavailableUris, unavailableUris,
showMemberBadge,
} = props; } = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
@ -196,6 +198,7 @@ export default function ClaimList(props: Props) {
showEdit={showEdit} showEdit={showEdit}
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps} dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
unavailableUris={unavailableUris} unavailableUris={unavailableUris}
showMemberBadge={showMemberBadge}
/> />
); );

View file

@ -9,6 +9,7 @@ import { doClaimSearch } from 'redux/actions/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
import { doFetchUserMemberships } from 'redux/actions/user';
import { selectClientSetting, selectShowMatureContent, selectLanguage } from 'redux/selectors/settings'; import { selectClientSetting, selectShowMatureContent, selectLanguage } from 'redux/selectors/settings';
import { selectModerationBlockList } from 'redux/selectors/comments'; import { selectModerationBlockList } from 'redux/selectors/comments';
import ClaimListDiscover from './view'; import ClaimListDiscover from './view';
@ -31,6 +32,7 @@ const select = (state, props) => ({
const perform = { const perform = {
doClaimSearch, doClaimSearch,
doFetchViewCount, doFetchViewCount,
doFetchUserMemberships,
}; };
export default connect(select, perform)(ClaimListDiscover); export default connect(select, perform)(ClaimListDiscover);

View file

@ -19,6 +19,7 @@ import LangFilterIndicator from 'component/langFilterIndicator';
import ClaimListHeader from 'component/claimListHeader'; import ClaimListHeader from 'component/claimListHeader';
import useFetchViewCount from 'effects/use-fetch-view-count'; import useFetchViewCount from 'effects/use-fetch-view-count';
import { useIsLargeScreen } from 'effects/use-screensize'; import { useIsLargeScreen } from 'effects/use-screensize';
import useGetUserMemberships from 'effects/use-get-user-memberships';
type Props = { type Props = {
uris: Array<string>, uris: Array<string>,
@ -98,6 +99,7 @@ type Props = {
// --- perform --- // --- perform ---
doClaimSearch: ({}) => void, doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void, doFetchViewCount: (claimIdCsv: string) => void,
doFetchUserMemberships: (claimIdCsv: string) => void,
hideLayoutButton?: boolean, hideLayoutButton?: boolean,
loadedCallback?: (number) => void, loadedCallback?: (number) => void,
@ -177,6 +179,7 @@ function ClaimListDiscover(props: Props) {
maxClaimRender, maxClaimRender,
useSkeletonScreen = true, useSkeletonScreen = true,
excludeUris = [], excludeUris = [],
doFetchUserMemberships,
swipeLayout = false, swipeLayout = false,
} = props; } = props;
const didNavigateForward = history.action === 'PUSH'; const didNavigateForward = history.action === 'PUSH';
@ -608,9 +611,14 @@ function ClaimListDiscover(props: Props) {
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount); useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount);
const shouldFetchUserMemberships = true;
const arrayOfContentUris = renderUris;
const convertClaimUrlsToIds = claimsByUri;
useGetUserMemberships(shouldFetchUserMemberships, arrayOfContentUris, convertClaimUrlsToIds, doFetchUserMemberships);
React.useEffect(() => { React.useEffect(() => {
if (shouldPerformSearch) { if (shouldPerformSearch) {
const searchOptions = JSON.parse(optionsStringForEffect); const searchOptions = JSON.parse(optionsStringForEffect);

View file

@ -90,6 +90,7 @@ type Props = {
showEdit?: boolean, showEdit?: boolean,
dragHandleProps?: any, dragHandleProps?: any,
unavailableUris?: Array<string>, unavailableUris?: Array<string>,
showMemberBadge?: boolean,
}; };
const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => { const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
@ -152,6 +153,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
showEdit, showEdit,
dragHandleProps, dragHandleProps,
unavailableUris, unavailableUris,
showMemberBadge,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -366,7 +368,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
{isChannelUri && claim ? ( {isChannelUri && claim ? (
<UriIndicator focusable={false} uri={uri} link> <UriIndicator focusable={false} uri={uri} link>
<ChannelThumbnail uri={uri} small={type === 'inline'} /> <ChannelThumbnail uri={uri} small={type === 'inline'} showMemberBadge={showMemberBadge} checkMembership={false} />
</UriIndicator> </UriIndicator>
) : ( ) : (
<> <>
@ -411,11 +413,16 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
{!isChannelUri && signingChannel && ( {!isChannelUri && signingChannel && (
<div className="claim-preview__channel-staked"> <div className="claim-preview__channel-staked">
<UriIndicator focusable={false} uri={uri} link hideAnonymous> <UriIndicator focusable={false} uri={uri} link hideAnonymous>
<ChannelThumbnail uri={signingChannel.permanent_url} xsmall /> <ChannelThumbnail uri={signingChannel.permanent_url} xsmall showMemberBadge={showMemberBadge} checkMembership={false} />
</UriIndicator> </UriIndicator>
</div> </div>
)} )}
<ClaimPreviewSubtitle uri={uri} type={type} showAtSign={isChannelUri} /> <ClaimPreviewSubtitle
uri={uri}
type={type}
showAtSign={isChannelUri}
showMemberBadge={!showMemberBadge}
/>
{(pending || !!reflectingProgress) && <PublishPending uri={uri} />} {(pending || !!reflectingProgress) && <PublishPending uri={uri} />}
{channelSubscribers} {channelSubscribers}
</div> </div>

View file

@ -21,11 +21,24 @@ type Props = {
lang: string, lang: string,
fetchSubCount: (string) => void, fetchSubCount: (string) => void,
subCount: number, subCount: number,
showMemberBadge?: boolean,
}; };
// previews used in channel overview and homepage (and other places?) // previews used in channel overview and homepage (and other places?)
function ClaimPreviewSubtitle(props: Props) { function ClaimPreviewSubtitle(props: Props) {
const { pending, uri, claim, type, beginPublish, isLivestream, fetchSubCount, subCount, showAtSign, lang } = props; const {
pending,
uri,
claim,
type,
beginPublish,
isLivestream,
fetchSubCount,
subCount,
showAtSign,
lang,
showMemberBadge,
} = props;
const isChannel = claim && claim.value_type === 'channel'; const isChannel = claim && claim.value_type === 'channel';
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
@ -47,7 +60,7 @@ function ClaimPreviewSubtitle(props: Props) {
<div className="media__subtitle"> <div className="media__subtitle">
{claim ? ( {claim ? (
<React.Fragment> <React.Fragment>
<UriIndicator uri={uri} showAtSign={showAtSign} link />{' '} <UriIndicator uri={uri} showAtSign={showAtSign} showMemberBadge={showMemberBadge} link />{' '}
{!pending && claim && ( {!pending && claim && (
<> <>
{isChannel && type !== 'inline' && ( {isChannel && type !== 'inline' && (

View file

@ -243,7 +243,7 @@ function ClaimPreviewTile(props: Props) {
) : ( ) : (
<React.Fragment> <React.Fragment>
<UriIndicator focusable={false} uri={uri} link hideAnonymous> <UriIndicator focusable={false} uri={uri} link hideAnonymous>
<ChannelThumbnail uri={channelUri} xsmall /> <ChannelThumbnail uri={channelUri} xsmall checkMembership={false} />
</UriIndicator> </UriIndicator>
<div className="claim-tile__about"> <div className="claim-tile__about">

View file

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { selectClaimSearchByQuery, selectFetchingClaimSearchByQuery, selectClaimsByUri } from 'redux/selectors/claims'; import { selectClaimSearchByQuery, selectFetchingClaimSearchByQuery, selectClaimsByUri } from 'redux/selectors/claims';
import { doClaimSearch } from 'redux/actions/claims'; import { doClaimSearch } from 'redux/actions/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { MATURE_TAGS } from 'constants/tags'; import { MATURE_TAGS } from 'constants/tags';
import { doFetchViewCount } from 'lbryinc'; import { doFetchViewCount } from 'lbryinc';
@ -37,6 +38,7 @@ const select = (state, props) => {
const perform = { const perform = {
doClaimSearch, doClaimSearch,
doFetchViewCount, doFetchViewCount,
doFetchUserMemberships,
}; };
export default withRouter(connect(select, perform)(ClaimListDiscover)); export default withRouter(connect(select, perform)(ClaimListDiscover));

View file

@ -4,6 +4,7 @@ import React from 'react';
import ClaimPreviewTile from 'component/claimPreviewTile'; import ClaimPreviewTile from 'component/claimPreviewTile';
import useFetchViewCount from 'effects/use-fetch-view-count'; import useFetchViewCount from 'effects/use-fetch-view-count';
import useLastVisibleItem from 'effects/use-last-visible-item'; import useLastVisibleItem from 'effects/use-last-visible-item';
import useGetUserMemberships from 'effects/use-get-user-memberships';
function urisEqual(prev: ?Array<string>, next: ?Array<string>) { function urisEqual(prev: ?Array<string>, next: ?Array<string>) {
if (!prev || !next) { if (!prev || !next) {
@ -56,6 +57,7 @@ type Props = {
// --- perform --- // --- perform ---
doClaimSearch: ({}) => void, doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void, doFetchViewCount: (claimIdCsv: string) => void,
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
function ClaimTilesDiscover(props: Props) { function ClaimTilesDiscover(props: Props) {
@ -74,6 +76,7 @@ function ClaimTilesDiscover(props: Props) {
doFetchViewCount, doFetchViewCount,
pageSize = 8, pageSize = 8,
optionsStringified, optionsStringified,
doFetchUserMemberships,
} = props; } = props;
// reference to the claim-grid // reference to the claim-grid
@ -117,6 +120,10 @@ function ClaimTilesDiscover(props: Props) {
// populate the view counts for the current claim uris // populate the view counts for the current claim uris
useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount); useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, uris, claimsByUri, doFetchUserMemberships);
// Run `doClaimSearch` // Run `doClaimSearch`
React.useEffect(() => { React.useEffect(() => {
if (shouldPerformSearch) { if (shouldPerformSearch) {

View file

@ -5,12 +5,12 @@ import {
selectThumbnailForUri, selectThumbnailForUri,
selectHasChannels, selectHasChannels,
selectMyClaimIdsRaw, selectMyClaimIdsRaw,
selectOdyseeMembershipForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { doCommentUpdate, doCommentList } from 'redux/actions/comments'; import { doCommentUpdate, doCommentList } from 'redux/actions/comments';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { doClearPlayingUri } from 'redux/actions/content'; import { doClearPlayingUri } from 'redux/actions/content';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { import {
selectLinkedCommentAncestors, selectLinkedCommentAncestors,
selectOthersReactsForComment, selectOthersReactsForComment,
@ -18,6 +18,9 @@ import {
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { selectPlayingUri } from 'redux/selectors/content'; import { selectPlayingUri } from 'redux/selectors/content';
import {
selectUserVerifiedEmail,
} from 'redux/selectors/user';
import Comment from './view'; import Comment from './view';
const select = (state, props) => { const select = (state, props) => {
@ -33,7 +36,7 @@ const select = (state, props) => {
claim: makeSelectClaimForUri(uri)(state), claim: makeSelectClaimForUri(uri)(state),
thumbnail: channel_url && selectThumbnailForUri(state, channel_url), thumbnail: channel_url && selectThumbnailForUri(state, channel_url),
channelIsBlocked: channel_url && makeSelectChannelIsMuted(channel_url)(state), channelIsBlocked: channel_url && makeSelectChannelIsMuted(channel_url)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, commentingEnabled: Boolean(selectUserVerifiedEmail(state)),
othersReacts: selectOthersReactsForComment(state, reactionKey), othersReacts: selectOthersReactsForComment(state, reactionKey),
activeChannelClaim, activeChannelClaim,
hasChannels: selectHasChannels(state), hasChannels: selectHasChannels(state),
@ -41,6 +44,7 @@ const select = (state, props) => {
stakedLevel: selectStakedLevelForChannelUri(state, channel_url), stakedLevel: selectStakedLevelForChannelUri(state, channel_url),
linkedCommentAncestors: selectLinkedCommentAncestors(state), linkedCommentAncestors: selectLinkedCommentAncestors(state),
totalReplyPages: makeSelectTotalReplyPagesForParentId(comment_id)(state), totalReplyPages: makeSelectTotalReplyPagesForParentId(comment_id)(state),
selectOdyseeMembershipForUri: channel_url && selectOdyseeMembershipForUri(state, channel_url),
}; };
}; };

View file

@ -30,6 +30,7 @@ import OptimizedImage from 'component/optimizedImage';
import { getChannelFromClaim } from 'util/claim'; import { getChannelFromClaim } from 'util/claim';
import { parseSticker } from 'util/comments'; import { parseSticker } from 'util/comments';
import { useIsMobile } from 'effects/use-screensize'; import { useIsMobile } from 'effects/use-screensize';
import PremiumBadge from 'component/common/premium-badge';
const AUTO_EXPAND_ALL_REPLIES = false; const AUTO_EXPAND_ALL_REPLIES = false;
@ -64,6 +65,7 @@ type Props = {
supportDisabled: boolean, supportDisabled: boolean,
setQuickReply: (any) => void, setQuickReply: (any) => void,
quickReply: any, quickReply: any,
selectOdyseeMembershipForUri: string,
}; };
const LENGTH_TO_COLLAPSE = 300; const LENGTH_TO_COLLAPSE = 300;
@ -93,6 +95,7 @@ function CommentView(props: Props) {
supportDisabled, supportDisabled,
setQuickReply, setQuickReply,
quickReply, quickReply,
selectOdyseeMembershipForUri,
} = props; } = props;
const { const {
@ -256,18 +259,15 @@ function CommentView(props: Props) {
> >
<div className="comment__thumbnail-wrapper"> <div className="comment__thumbnail-wrapper">
{authorUri ? ( {authorUri ? (
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" /> <ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" checkMembership={false} />
) : ( ) : (
<ChannelThumbnail xsmall className="comment__author-thumbnail" /> <ChannelThumbnail xsmall className="comment__author-thumbnail" checkMembership={false} />
)} )}
</div> </div>
<div className="comment__body-container"> <div className="comment__body-container">
<div className="comment__meta"> <div className="comment__meta">
<div className="comment__meta-information"> <div className="comment__meta-information">
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
{!author ? ( {!author ? (
<span className="comment__author">{__('Anonymous')}</span> <span className="comment__author">{__('Anonymous')}</span>
) : ( ) : (
@ -277,9 +277,13 @@ function CommentView(props: Props) {
})} })}
link link
uri={authorUri} uri={authorUri}
comment
showAtSign showAtSign
/> />
)} )}
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_ADMIN} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
<PremiumBadge membership={selectOdyseeMembershipForUri} linkPage />
<Button <Button
className="comment__time" className="comment__time"
onClick={handleTimeClick} onClick={handleTimeClick}
@ -358,6 +362,7 @@ function CommentView(props: Props) {
promptLinks promptLinks
parentCommentId={commentId} parentCommentId={commentId}
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
hasMembership={selectOdyseeMembershipForUri}
/> />
</Expandable> </Expandable>
) : ( ) : (
@ -366,6 +371,7 @@ function CommentView(props: Props) {
promptLinks promptLinks
parentCommentId={commentId} parentCommentId={commentId}
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
hasMembership={selectOdyseeMembershipForUri}
/> />
)} )}
</div> </div>

View file

@ -1,5 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri, selectClaimIsMine, selectFetchingMyChannels } from 'redux/selectors/claims'; import { selectClaimForUri,
selectClaimIsMine,
selectFetchingMyChannels,
selectClaimsByUri,
} from 'redux/selectors/claims';
import { import {
selectTopLevelCommentsForUri, selectTopLevelCommentsForUri,
makeSelectTopLevelTotalPagesForUri, makeSelectTopLevelTotalPagesForUri,
@ -16,6 +20,7 @@ import {
import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments'; import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { doFetchUserMemberships } from 'redux/actions/user';
import CommentsList from './view'; import CommentsList from './view';
const select = (state, props) => { const select = (state, props) => {
@ -41,6 +46,7 @@ const select = (state, props) => {
myReactsByCommentId: selectMyReacts(state), myReactsByCommentId: selectMyReacts(state),
othersReactsById: selectOthersReacts(state), othersReactsById: selectOthersReacts(state),
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id, activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,
claimsByUri: selectClaimsByUri(state),
}; };
}; };
@ -49,6 +55,7 @@ const perform = {
fetchComment: doCommentById, fetchComment: doCommentById,
fetchReacts: doCommentReactList, fetchReacts: doCommentReactList,
resetComments: doCommentReset, resetComments: doCommentReset,
doFetchUserMemberships,
}; };
export default connect(select, perform)(CommentsList); export default connect(select, perform)(CommentsList);

View file

@ -15,6 +15,7 @@ import Empty from 'component/common/empty';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import useGetUserMemberships from 'effects/use-get-user-memberships';
const DEBOUNCE_SCROLL_HANDLER_MS = 200; const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -50,6 +51,8 @@ type Props = {
fetchComment: (commentId: string) => void, fetchComment: (commentId: string) => void,
fetchReacts: (commentIds: Array<string>) => Promise<any>, fetchReacts: (commentIds: Array<string>) => Promise<any>,
resetComments: (claimId: string) => void, resetComments: (claimId: string) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function CommentList(props: Props) { export default function CommentList(props: Props) {
@ -76,6 +79,8 @@ export default function CommentList(props: Props) {
fetchComment, fetchComment,
fetchReacts, fetchReacts,
resetComments, resetComments,
claimsByUri,
doFetchUserMemberships,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -100,6 +105,22 @@ export default function CommentList(props: Props) {
Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS
); );
// get commenter claim ids for checking premium status
const commenterClaimIds = topLevelComments.map(function(comment) {
return comment.channel_id;
});
// update premium status
const shouldFetchUserMemberships = true;
useGetUserMemberships(
shouldFetchUserMemberships,
commenterClaimIds,
claimsByUri,
doFetchUserMemberships,
[topLevelComments],
true,
);
function changeSort(newSort) { function changeSort(newSort) {
if (sort !== newSort) { if (sort !== newSort) {
setSort(newSort); setSort(newSort);

View file

@ -1,35 +1,38 @@
// @flow // @flow
import 'scss/component/_comment-badge.scss'; import 'scss/component/_comment-badge.scss';
import classnames from 'classnames';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import React from 'react'; import React from 'react';
import Tooltip from 'component/common/tooltip'; import Tooltip from 'component/common/tooltip';
const LABEL_TYPES = {
ADMIN: 'Admin',
MOD: 'Moderator',
};
type Props = { type Props = {
icon: string, icon: string,
label: string, label: string,
size?: number, size?: number,
placement?: string,
hideTooltip?: boolean,
className?: string,
}; };
export default function CommentBadge(props: Props) { export default function CommentBadge(props: Props) {
const { icon, label, size = 20 } = props; const { icon, label, size = 20, placement = 'top', hideTooltip, className } = props;
return ( return (
<Tooltip title={label} placement="top"> <BadgeWrapper title={label} placement={placement} hideTooltip={hideTooltip} className={className}>
<span <span className="comment__badge">
className={classnames('comment__badge', {
'comment__badge--globalMod': label === LABEL_TYPES.ADMIN,
'comment__badge--mod': label === LABEL_TYPES.MOD,
})}
>
<Icon icon={icon} size={size} /> <Icon icon={icon} size={size} />
</span> </span>
</Tooltip> </BadgeWrapper>
); );
} }
type WrapperProps = {
hideTooltip?: boolean,
children: any,
};
const BadgeWrapper = (props: WrapperProps) => {
const { hideTooltip, children, ...tooltipProps } = props;
return !hideTooltip ? <Tooltip {...tooltipProps}>{children}</Tooltip> : children;
};

View file

@ -2537,21 +2537,58 @@ export const icons = {
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlSpace="preserve" xmlSpace="preserve"
> >
<style type="text/css">{'.st0{fill:FF3850}.st1{fill:#181021}.st2{fill:#FFFFFF}'}</style> <style type="text/css">
{'.st0--badge-mod{fill:#ff3850}.st1--badge-mod{fill:#181021}.st2--badge-mod{fill:#FFFFFF}'}
</style>
<g> <g>
<g> <g>
<path <path
className="st0" className="st0--badge-mod"
d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z" d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z"
/> />
</g> </g>
</g> </g>
<path <path
className="st1" className="st1--badge-mod"
d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z" d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z"
/> />
<path <path
className="st2" className="st2--badge-mod"
d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z"
/>
</svg>
),
[ICONS.BADGE_ADMIN]: (props: IconProps) => (
<svg
{...props}
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
width="24"
height="24"
viewBox="0 0 24 24"
xmlSpace="preserve"
>
<style type="text/css">
{'.st0--badge-admin{fill:#fe7500}.st1--badge-admin{fill:#181021}.st2--badge-admin{fill:#FFFFFF}'}
</style>
<g>
<g>
<path
className="st0--badge-admin"
d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z"
/>
</g>
</g>
<path
className="st1--badge-admin"
d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z"
/>
<path
className="st2--badge-admin"
d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z" d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z"
/> />
</svg> </svg>
@ -2570,26 +2607,30 @@ export const icons = {
viewBox="-1182 401 24 24" viewBox="-1182 401 24 24"
xmlSpace="preserve" xmlSpace="preserve"
> >
<style type="text/css">{'.st0{fill:#FF5490}.st1{fill:#81BBB9}.st2{fill:#2E2A2F}.st3{fill:#FFFFFF}'}</style> <style type="text/css">
{
'.st0--badge-streamer{fill:#FF5490}.st1--badge-streamer{fill:#81BBB9}.st2--badge-streamer{fill:#2E2A2F}.st3--badge-streamer{fill:#FFFFFF}'
}
</style>
<path <path
className="st0" className="st0--badge-streamer"
d="M-1169.8,406.4c-4.3,0-7.8,3.5-7.8,7.8c0,0.4,0,0.8,0.1,1.1h1c-0.1-0.4-0.1-0.7-0.1-1.1c0-3.7,3-6.8,6.8-6.8 s6.8,3,6.8,6.8c0,0.4,0,0.8-0.1,1.1h1c0.1-0.4,0.1-0.7,0.1-1.1C-1162.1,409.9-1165.5,406.4-1169.8,406.4z" d="M-1169.8,406.4c-4.3,0-7.8,3.5-7.8,7.8c0,0.4,0,0.8,0.1,1.1h1c-0.1-0.4-0.1-0.7-0.1-1.1c0-3.7,3-6.8,6.8-6.8 s6.8,3,6.8,6.8c0,0.4,0,0.8-0.1,1.1h1c0.1-0.4,0.1-0.7,0.1-1.1C-1162.1,409.9-1165.5,406.4-1169.8,406.4z"
/> />
<path <path
className="st0" className="st0--badge-streamer"
d="M-1180,414.2c0-5.6,4.6-10.2,10.2-10.2c5.6,0,10.2,4.6,10.2,10.2c0,2.2-0.7,4.3-1.9,5.9l0.8,0.6 c1.3-1.8,2.1-4.1,2.1-6.5c0-6.2-5-11.2-11.2-11.2c-6.2,0-11.2,5-11.2,11.2c0,2.1,0.6,4.1,1.6,5.8l1-0.3 C-1179.4,418-1180,416.2-1180,414.2z" d="M-1180,414.2c0-5.6,4.6-10.2,10.2-10.2c5.6,0,10.2,4.6,10.2,10.2c0,2.2-0.7,4.3-1.9,5.9l0.8,0.6 c1.3-1.8,2.1-4.1,2.1-6.5c0-6.2-5-11.2-11.2-11.2c-6.2,0-11.2,5-11.2,11.2c0,2.1,0.6,4.1,1.6,5.8l1-0.3 C-1179.4,418-1180,416.2-1180,414.2z"
/> />
<path className="st1" d="M-1163.7,419.4" /> <path className="st1--badge-streamer" d="M-1163.7,419.4" />
<path <path
className="st1" className="st1--badge-streamer"
d="M-1165.6,418.5c0-0.1,0-3.6,0-3.6c0-1.9-1-4.3-4.4-4.3s-4.4,2.4-4.4,4.3c0,0,0,3.6,0,3.6 c-1.4,0.2-1.8,0.7-1.8,0.7s2.2,2.7,6.2,2.7s6.2-2.7,6.2-2.7S-1164.2,418.7-1165.6,418.5z" d="M-1165.6,418.5c0-0.1,0-3.6,0-3.6c0-1.9-1-4.3-4.4-4.3s-4.4,2.4-4.4,4.3c0,0,0,3.6,0,3.6 c-1.4,0.2-1.8,0.7-1.8,0.7s2.2,2.7,6.2,2.7s6.2-2.7,6.2-2.7S-1164.2,418.7-1165.6,418.5z"
/> />
<path <path
className="st2" className="st2--badge-streamer"
d="M-1169.2,418.5h-1.5c-1.7,0-3.1-0.6-3.1-2.2v-1.9c0-2.1,1.6-3,3.9-3s3.9,0.9,3.9,3v1.9 C-1166.1,417.8-1167.5,418.5-1169.2,418.5z" d="M-1169.2,418.5h-1.5c-1.7,0-3.1-0.6-3.1-2.2v-1.9c0-2.1,1.6-3,3.9-3s3.9,0.9,3.9,3v1.9 C-1166.1,417.8-1167.5,418.5-1169.2,418.5z"
/> />
<path <path
className="st3" className="st3--badge-streamer"
d="M-1167.8,416.2c-0.2,0-0.4-0.2-0.4-0.4v-1.1c0-0.2,0-1-1.2-1c-0.2,0-0.4-0.2-0.4-0.4s0.2-0.4,0.4-0.4 c1.2,0,2,0.6,2,1.7v1.1C-1167.4,416.1-1167.6,416.2-1167.8,416.2z" d="M-1167.8,416.2c-0.2,0-0.4-0.2-0.4-0.4v-1.1c0-0.2,0-1-1.2-1c-0.2,0-0.4-0.2-0.4-0.4s0.2-0.4,0.4-0.4 c1.2,0,2,0.6,2,1.7v1.1C-1167.4,416.1-1167.6,416.2-1167.8,416.2z"
/> />
</svg> </svg>
@ -2717,6 +2758,368 @@ export const icons = {
<path d="M23.5,12.11a7,7,0,0,1-3.27,5.59.26.26,0,0,1-.32,0,.27.27,0,0,1-.05-.31A2.71,2.71,0,0,0,20,17c.65-1.4.5-2.85-.34-3.25s-2,.41-2.67,1.77c.06-.93-.26-1.7-.86-1.88-1.27-.4-1.77,1.24-4.17,5.44-2.44-4.27-2.9-5.84-4.17-5.44-.6.18-.92.95-.86,1.88-.66-1.36-1.84-2.15-2.67-1.77S3.31,15.63,4,17a2.71,2.71,0,0,0,.18.34.27.27,0,0,1,0,.31.26.26,0,0,1-.32,0A7,7,0,0,1,.5,12.11C.5,8.93,3.17,6.18,7,4.9a.25.25,0,0,1,.32.3L7,6.73a3.37,3.37,0,0,0,.78,3,1,1,0,0,0,1.1.28,1,1,0,0,0,.65-.94V5.61a.25.25,0,0,1,.4-.2l1.6,1.2h1l1.6-1.2a.25.25,0,0,1,.4.2V9.05a1,1,0,0,0,.65.94,1,1,0,0,0,1.1-.28,3.35,3.35,0,0,0,.78-3L16.65,5.2A.25.25,0,0,1,17,4.9C20.83,6.18,23.5,8.93,23.5,12.11Z" /> <path d="M23.5,12.11a7,7,0,0,1-3.27,5.59.26.26,0,0,1-.32,0,.27.27,0,0,1-.05-.31A2.71,2.71,0,0,0,20,17c.65-1.4.5-2.85-.34-3.25s-2,.41-2.67,1.77c.06-.93-.26-1.7-.86-1.88-1.27-.4-1.77,1.24-4.17,5.44-2.44-4.27-2.9-5.84-4.17-5.44-.6.18-.92.95-.86,1.88-.66-1.36-1.84-2.15-2.67-1.77S3.31,15.63,4,17a2.71,2.71,0,0,0,.18.34.27.27,0,0,1,0,.31.26.26,0,0,1-.32,0A7,7,0,0,1,.5,12.11C.5,8.93,3.17,6.18,7,4.9a.25.25,0,0,1,.32.3L7,6.73a3.37,3.37,0,0,0,.78,3,1,1,0,0,0,1.1.28,1,1,0,0,0,.65-.94V5.61a.25.25,0,0,1,.4-.2l1.6,1.2h1l1.6-1.2a.25.25,0,0,1,.4.2V9.05a1,1,0,0,0,.65.94,1,1,0,0,0,1.1-.28,3.35,3.35,0,0,0,.78-3L16.65,5.2A.25.25,0,0,1,17,4.9C20.83,6.18,23.5,8.93,23.5,12.11Z" />
</g> </g>
), ),
[ICONS.EARLY_ACCESS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{
'.early-access--st0{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.early-access--st1{fill:#DCBEA2;}'
}
</style>
<circle className="early-access--st0" cx="304.9" cy="346.6" r="13.7" />
<g>
<ellipse className="early-access--st0" cx="301.2" cy="346.5" rx="3.5" ry="3.8" />
<line className="early-access--st0" x1="304.7" y1="346.5" x2="312.5" y2="346.5" />
<line className="early-access--st0" x1="310.3" y1="346.6" x2="310.3" y2="349.3" />
</g>
<circle className="early-access--st0" cx="304.9" cy="390.6" r="13.7" />
<path
className="early-access--st0"
d="M316.2,296.7v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="early-access--st0"
points="318.7,290.8 304.4,306.8 301.3,301.3 295.8,298.2 301.3,295 304.4,289.5 307.5,295 "
/>
<polyline className="early-access--st0" points="299,310.2 299,316.4 304.8,313.1 309.9,316.4 309.9,310.2 " />
<line className="early-access--st0" x1="314.7" y1="380.8" x2="295.1" y2="400.5" />
<text
transform="matrix(1 0 0 1 294.7307 394.0567)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_53_">
<g id="XMLID_493_">
<path
id="XMLID_494_"
className="early-access--st1"
d="M16,1.6C8,1.6,1.6,8,1.6,16S8,30.4,16,30.4S30.4,24,30.4,16S24,1.6,16,1.6z M16,28.9C8.9,28.9,3.1,23.1,3.1,16S8.9,3.1,16,3.1S28.9,8.9,28.9,16S23.1,28.9,16,28.9z M12.3,11.4c-2.3,0-4.2,2-4.2,4.5s1.9,4.5,4.2,4.5c2.1,0,3.8-1.6,4.1-3.8h4.2v2h1.5v-2h1.4v-1.5h-7.2C16.1,13,14.4,11.4,12.3,11.4z M12.3,18.9c-1.5,0-2.7-1.3-2.7-3s1.2-3,2.7-3c1.5,0,2.7,1.3,2.7,3S13.8,18.9,12.3,18.9z"
/>
</g>
</g>
</svg>
),
[ICONS.MEMBER_BADGE]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{
'.member-bage--st0{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.member-bage--st1{fill:#DCBEA2;}'
}
</style>
<circle className="member-bage--st0" cx="304.9" cy="399.4" r="13.7" />
<g>
<ellipse className="member-bage--st0" cx="301.2" cy="399.3" rx="3.5" ry="3.8" />
<line className="member-bage--st0" x1="304.7" y1="399.3" x2="312.5" y2="399.3" />
<line className="member-bage--st0" x1="310.3" y1="399.3" x2="310.3" y2="402" />
</g>
<circle className="member-bage--st0" cx="304.9" cy="443.4" r="13.7" />
<path
className="member-bage--st0"
d="M316.2,349.5v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4
c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="member-bage--st0"
points="318.7,343.5 304.4,359.6 301.3,354 295.8,350.9 301.3,347.8 304.4,342.2 307.5,347.8 "
/>
<polyline className="member-bage--st0" points="299,363 299,369.1 304.8,365.9 309.9,369.1 309.9,363 " />
<line className="member-bage--st0" x1="314.7" y1="433.6" x2="295.1" y2="453.2" />
<text
transform="matrix(1 0 0 1 294.7307 446.8067)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_187_">
<g id="XMLID_250_">
<path
id="XMLID_251_"
className="member-bage--st1"
d="M26.7,19c0,0.7-0.4,1.3-0.9,1.6l-9,5.2c-0.6,0.3-1.3,0.3-1.9,0l-9-5.2C5.3,20.3,5,19.6,5,19
V8.6c0-0.7,0.4-1.3,0.9-1.6l9-5.2c0.6-0.3,1.3-0.3,1.9,0l9,5.2l0.8-1.3l-9-5.2c-1-0.6-2.3-0.6-3.4,0l-9,5.2
c-1,0.6-1.7,1.7-1.7,2.9V19c0,1.2,0.6,2.3,1.7,2.9l9,5.2c0.5,0.3,1.1,0.5,1.7,0.5s1.2-0.2,1.7-0.5l9-5.2c1-0.6,1.7-1.7,1.7-2.9
v-6.4h-1.5V19z M13.1,16.5L8.5,14l4.6-2.6l2.6-4.6l2.5,4.4l1.3-0.7l-3.8-6.7L12,10.3L5.5,14l6.5,3.7l3.5,6.3l15-16.8l-1.1-1
L15.8,21.3L13.1,16.5z M20.4,29.7L16,27.8l-4.7,2v-4.1H9.8v6.3l6.2-2.5l5.9,2.6v-6.3h-1.5V29.7z"
/>
</g>
</g>
</svg>
),
[ICONS.NO_ADS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.st0--no-ads{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.st1--no-ads{fill:#DCBEA2;}'}
</style>
<circle className="st0--no-ads" cx="304.9" cy="297.6" r="13.7" />
<g>
<ellipse className="st0--no-ads" cx="301.2" cy="297.5" rx="3.5" ry="3.8" />
<line className="st0--no-ads" x1="304.7" y1="297.5" x2="312.5" y2="297.5" />
<line className="st0--no-ads" x1="310.3" y1="297.6" x2="310.3" y2="300.3" />
</g>
<circle className="st0--no-ads" cx="304.9" cy="341.6" r="13.7" />
<path
className="st0--no-ads"
d="M316.2,247.7v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4
c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="st0--no-ads"
points="318.7,241.8 304.4,257.8 301.3,252.3 295.8,249.2 301.3,246 304.4,240.5 307.5,246 "
/>
<polyline className="st0--no-ads" points="299,261.2 299,267.4 304.8,264.1 309.9,267.4 309.9,261.2 " />
<line className="st0--no-ads" x1="314.7" y1="331.8" x2="295.1" y2="351.5" />
<text
transform="matrix(1 0 0 1 294.7307 345.0567)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_109_">
<path
id="XMLID_190_"
className="st1--no-ads"
d="M16,1.6C8,1.6,1.6,8,1.6,16S8,30.4,16,30.4S30.4,24,30.4,16S24,1.6,16,1.6z M16,3.1
c3.3,0,6.3,1.3,8.6,3.3L18,13c-0.2-0.1-0.3-0.3-0.5-0.4c-0.5-0.3-1.1-0.4-1.7-0.4h-2.2v5.3l-1.2,1.2l-2.4-6.4H8.5l-2.7,7.2h1.6
l0.5-1.5h2.6l0.5,1.5h0.5l-5.2,5.2c-2-2.3-3.3-5.3-3.3-8.6C3.1,8.9,8.9,3.1,16,3.1z M17.6,15.6V16c0,0.7-0.2,1.3-0.5,1.6
s-0.8,0.6-1.3,0.6H15V18l2.5-2.5C17.6,15.5,17.6,15.6,17.6,15.6z M15,15.9v-2.5h0.7c0.6,0,1,0.2,1.3,0.5L15,15.9z M10.2,16.7H8.3
L9.2,14L10.2,16.7z M16,28.9c-3.3,0-6.3-1.3-8.6-3.3l6.2-6.2h2.1c0.6,0,1.2-0.1,1.7-0.4c0.5-0.3,0.9-0.7,1.2-1.2
c0.3-0.5,0.4-1.1,0.4-1.8v-0.3c0-0.5-0.1-0.9-0.3-1.4l6.8-6.8c2,2.3,3.3,5.3,3.3,8.6C28.9,23.1,23.1,28.9,16,28.9z"
/>
<path
id="XMLID_195_"
className="st1--no-ads"
d="M23.2,15.1c-0.5-0.1-0.8-0.3-1-0.4c-0.2-0.2-0.4-0.4-0.4-0.6c0-0.3,0.1-0.5,0.3-0.6
c0.2-0.2,0.5-0.2,0.9-0.2c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.3,0.4,0.3,0.8h1.5c0-0.4-0.1-0.8-0.3-1.2s-0.5-0.6-0.9-0.8
c-0.4-0.2-0.9-0.3-1.4-0.3c-0.5,0-1,0.1-1.4,0.3s-0.7,0.4-1,0.7c-0.2,0.3-0.3,0.7-0.3,1c0,0.8,0.4,1.4,1.2,1.8
c0.3,0.2,0.7,0.3,1.2,0.5c0.5,0.2,0.9,0.3,1.1,0.5c0.2,0.2,0.3,0.4,0.3,0.6c0,0.3-0.1,0.5-0.3,0.6c-0.2,0.1-0.5,0.2-0.8,0.2
c-1,0-1.4-0.4-1.4-1.2h-1.5c0,0.5,0.1,0.9,0.4,1.2c0.2,0.4,0.6,0.6,1,0.8s1,0.3,1.5,0.3c0.8,0,1.4-0.2,1.9-0.5s0.7-0.8,0.7-1.5
c0-0.6-0.2-1-0.6-1.4C24.6,15.7,24,15.4,23.2,15.1z"
/>
</g>
</svg>
),
[ICONS.PREMIUM]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={props.size || '40'}
height={props.size || '40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.premium--st0{fill:#898DB3;}'}
{'.premium--st1{fill:#D8D2E8;}'}
{'.premium--st2{fill:#CAC2DF;}'}
{'.premium--st3{opacity:0.27;fill:#74749A;}'}
{'.premium--st4{fill:none;stroke:#CAC2DF;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;}'}
{'.premium--st5{fill:#626092;}.premium--st6{opacity:0.2;fill:#FFFFFF;}'}
</style>
<path
id="XMLID_122_"
className="premium--st0"
d="M0,12.7v0.8c0,2.3,2,4.2,4.4,4.2h23.2c2.4,0,4.4-1.9,4.4-4.2v-0.8H0z"
/>
<path
id="XMLID_20_"
className="premium--st1"
d="M1.8,14.4c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9
c0.2-0.2,0.4-0.5,0.5-0.8H1.8z"
/>
<path
id="XMLID_27_"
className="premium--st2"
d="M2.3,15.2c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9H2.3z"
/>
<rect id="XMLID_28_" x="5.2" y="12.7" className="premium--st3" width="21.7" height="5.1" />
<path
id="XMLID_125_"
className="premium--st0"
d="M1.4,16.1v0.8c0,2.3,2,4.2,4.4,4.2h20.4c2.4,0,4.4-1.9,4.4-4.2v-0.8H1.4z"
/>
<path
id="XMLID_237_"
className="premium--st1"
d="M3.2,17.8c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9
c0.2-0.2,0.4-0.5,0.5-0.8H3.2z"
/>
<path
id="XMLID_120_"
className="premium--st2"
d="M3.7,18.6c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9H3.7z"
/>
<rect id="XMLID_29_" x="5.2" y="16.1" className="premium--st3" width="21.7" height="5.1" />
<path id="XMLID_75_" className="premium--st4" d="M6.4,14.3" />
<path id="XMLID_25_" className="premium--st4" d="M2.7,14.3" />
<path
id="XMLID_124_"
className="premium--st0"
d="M25.1,10.4l-7.9-4.6c-0.7-0.4-1.6-0.4-2.3,0l-7.9,4.6c-0.7,0.4-1.2,1.2-1.2,2v9.1
c0,0.8,0.4,1.6,1.2,2l7.9,4.6c0.4,0.2,0.8,0.3,1.2,0.3c0.4,0,0.8-0.1,1.2-0.3l7.9-4.6c0.7-0.4,1.2-1.2,1.2-2v-9.1
C26.2,11.6,25.8,10.8,25.1,10.4z"
/>
<path
id="XMLID_123_"
className="premium--st2"
d="M16.3,7.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1l-7.9,4.6c-0.2,0.1-0.3,0.3-0.3,0.5
v9.1c0,0.2,0.1,0.4,0.3,0.5l7.9,4.6c0.2,0.1,0.4,0.1,0.6,0l7.9-4.6c0.2-0.1,0.3-0.3,0.3-0.5v-9.1c0-0.2-0.1-0.4-0.3-0.5L16.3,7.3z"
/>
<polygon
id="XMLID_19_"
className="premium--st5"
points="20.5,20.1 11.6,20.1 11.2,14.3 14.2,15.8 16,12.4 17.9,15.8 21,14.2 "
/>
<polygon
id="XMLID_18_"
className="premium--st6"
points="16.1,20.8 21.1,20.8 21.7,13.2 18.1,14.9 16.1,11.3 16.1,7.8 23.9,12.3 23.9,21.6 16.1,25.9 "
/>
<polygon id="XMLID_17_" className="premium--st6" points="16.1,13.4 16.1,19.6 20.1,19.6 20.5,15.2 17.8,16.8 " />
</svg>
),
[ICONS.PREMIUM_PLUS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={props.size || '40'}
height={props.size || '40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.premium-plus--st0{fill:#C36017;}'}
{'.premium-plus--st1{fill:#FAC65D;}'}
{'.premium-plus--st2{fill:#F9B915;}'}
{'.premium-plus--st3{opacity:0.3;fill:#955000;}'}
{'.premium-plus--st4{fill:none;stroke:#CAC2DF;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;}'}
{'.premium-plus--st5{fill:#C95B16;}'}
{'.premium-plus--st6{opacity:0.2;fill:#FFFFFF;}'}
</style>
<path
id="XMLID_141_"
className="premium-plus--st0"
d="M0,11.7v0.8c0,2.3,2,4.2,4.4,4.2h23.2c2.4,0,4.4-1.9,4.4-4.2v-0.8H0z"
/>
<path
id="XMLID_133_"
className="premium-plus--st1"
d="M1.8,13.4c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9c0.2-0.2,0.4-0.5,0.5-0.8H1.8z"
/>
<path
id="XMLID_139_"
className="premium-plus--st2"
d="M2.3,14.2c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9H2.3z"
/>
<path
id="XMLID_136_"
className="premium-plus--st0"
d="M1.4,15.1v0.8c0,2.3,2,4.2,4.4,4.2h20.4c2.4,0,4.4-1.9,4.4-4.2v-0.8H1.4z"
/>
<path
id="XMLID_131_"
className="premium-plus--st1"
d="M3.2,16.8c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9c0.2-0.2,0.4-0.5,0.5-0.8H3.2z"
/>
<path
id="XMLID_134_"
className="premium-plus--st2"
d="M3.7,17.6c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9H3.7z"
/>
<path
id="XMLID_260_"
className="premium-plus--st0"
d="M2.6,18.6v0.8c0,2.3,2,4.2,4.4,4.2h18.2c2.4,0,4.1-1.9,4.1-4.2v-0.8H2.6z"
/>
<path
id="XMLID_137_"
className="premium-plus--st3"
d="M7.4,23.6h17.4c0.7,0,1.9-1.6,1.9-2.3l0.1-9.6H5.3v9.7C5.3,22.1,6.7,23.6,7.4,23.6z"
/>
<path
id="XMLID_257_"
className="premium-plus--st1"
d="M4.5,20.3c0.1,0.3,0.3,0.6,0.5,0.8C5.5,21.6,6.2,22,7,22h18.2c0.8,0,1.2-0.4,1.7-0.9c0.2-0.2,0.4-0.5,0.5-0.8H4.5z"
/>
<path
id="XMLID_254_"
className="premium-plus--st2"
d="M5,21.1C5.5,21.6,6.2,22,7,22h18.2c0.8,0,1.2-0.4,1.7-0.9H5z"
/>
<path id="XMLID_130_" className="premium-plus--st4" d="M6.4,14.3" />
<path id="XMLID_129_" className="premium-plus--st4" d="M2.7,14.3" />
<path
id="XMLID_128_"
className="premium-plus--st0"
d="M25.1,10.4l-7.9-4.6c-0.7-0.4-1.6-0.4-2.3,0l-7.9,4.6c-0.7,0.4-1.2,1.2-1.2,2v9.1c0,0.8,0.4,1.6,1.2,2l7.9,4.6c0.4,0.2,0.8,0.3,1.2,0.3c0.4,0,0.8-0.1,1.2-0.3l7.9-4.6c0.7-0.4,1.2-1.2,1.2-2v-9.1C26.2,11.6,25.8,10.8,25.1,10.4z"
/>
<path
id="XMLID_127_"
className="premium-plus--st2"
d="M16.3,7.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1l-7.9,4.6c-0.2,0.1-0.3,0.3-0.3,0.5v9.1c0,0.2,0.1,0.4,0.3,0.5l7.9,4.6c0.2,0.1,0.4,0.1,0.6,0l7.9-4.6c0.2-0.1,0.3-0.3,0.3-0.5v-9.1c0-0.2-0.1-0.4-0.3-0.5L16.3,7.3z"
/>
<polygon
id="XMLID_126_"
className="premium-plus--st5"
points="20.5,20.1 11.6,20.1 11.2,14.3 14.2,15.8 16,12.4 17.9,15.8 21,14.2 "
/>
<polygon
id="XMLID_24_"
className="premium-plus--st6"
points="16.1,20.8 21.1,20.8 21.7,13.2 18.1,14.9 16.1,11.3 16.1,7.8 23.9,12.3 23.9,21.616.1,25.9 "
/>
<polygon
id="XMLID_23_"
className="premium-plus--st6"
points="16.1,13.4 16.1,19.6 20.1,19.6 20.5,15.2 17.8,16.8 "
/>
</svg>
),
[ICONS.UPGRADE]: buildIcon(
<g>
<path d="m2 6 10-5 10 5M2 6v12l10 5 10-5V6" />
<circle cx={12} cy={10} r={5.25} />
<path d="M8.5 14.5 6 17h3l1.5 2.5 1-4h1l1 4L15 17h3l-2-2.5" />
</g>
),
[ICONS.FEATURED]: (props: IconProps) => { [ICONS.FEATURED]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props; const { size = 24, color = 'currentColor', ...otherProps } = props;

View file

@ -58,6 +58,7 @@ type MarkdownProps = {
disableTimestamps?: boolean, disableTimestamps?: boolean,
stakedLevel?: number, stakedLevel?: number,
setUserMention?: (boolean) => void, setUserMention?: (boolean) => void,
hasMembership?: string,
}; };
// **************************************************************************** // ****************************************************************************
@ -156,6 +157,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
disableTimestamps, disableTimestamps,
stakedLevel, stakedLevel,
setUserMention, setUserMention,
hasMembership,
} = props; } = props;
const strippedContent = content const strippedContent = content
@ -189,7 +191,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
parentCommentId={parentCommentId} parentCommentId={parentCommentId}
isMarkdownPost={isMarkdownPost} isMarkdownPost={isMarkdownPost}
simpleLinks={simpleLinks} simpleLinks={simpleLinks}
allowPreview={isStakeEnoughForPreview(stakedLevel)} allowPreview={isStakeEnoughForPreview(stakedLevel) || hasMembership}
setUserMention={setUserMention} setUserMention={setUserMention}
/> />
), ),
@ -198,7 +200,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
img: (imgProps) => { img: (imgProps) => {
const imageCdnUrl = const imageCdnUrl =
getThumbnailCdnUrl({ thumbnail: imgProps.src, width: 0, height: 0, quality: 85 }) || MISSING_THUMB_DEFAULT; getThumbnailCdnUrl({ thumbnail: imgProps.src, width: 0, height: 0, quality: 85 }) || MISSING_THUMB_DEFAULT;
if (isStakeEnoughForPreview(stakedLevel) && !isEmote(imgProps.title, imgProps.src)) { if ((isStakeEnoughForPreview(stakedLevel) || hasMembership) && !isEmote(imgProps.title, imgProps.src)) {
return <ZoomableImage {...imgProps} src={imageCdnUrl} />; return <ZoomableImage {...imgProps} src={imageCdnUrl} />;
} else { } else {
return ( return (
@ -206,7 +208,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
src={imageCdnUrl} src={imageCdnUrl}
alt={imgProps.alt} alt={imgProps.alt}
title={imgProps.title} title={imgProps.title}
helpText={__("This channel isn't staking enough Credits for inline image previews.")} helpText={__('Odysee Premium required to enable image previews')}
/> />
); );
} }

View file

@ -0,0 +1,52 @@
// @flow
import 'scss/component/_comment-badge.scss';
import * as ICONS from 'constants/icons';
import React from 'react';
import CommentBadge from './comment-badge';
import Button from 'component/button';
type Props = {
membership: ?string,
linkPage?: boolean,
placement?: string,
className?: string,
hideTooltip?: boolean,
};
export default function PremiumBadge(props: Props) {
const { membership, linkPage, placement, className, hideTooltip } = props;
const badgeToShow = membership === 'Premium' ? 'silver' : membership === 'Premium+' ? 'gold' : null;
if (!badgeToShow) return null;
const badgeProps = { size: 40, placement, hideTooltip, className };
return (
<BadgeWrapper linkPage={linkPage}>
{badgeToShow === 'silver' ? (
<CommentBadge label={__('Premium')} icon={ICONS.PREMIUM} {...badgeProps} />
) : (
badgeToShow === 'gold' && <CommentBadge label={__('Premium +')} icon={ICONS.PREMIUM_PLUS} {...badgeProps} />
)}
</BadgeWrapper>
);
}
type WrapperProps = {
linkPage?: boolean,
children: any,
};
const BadgeWrapper = (props: WrapperProps) => {
const { linkPage, children } = props;
return linkPage ? (
<Button navigate="/$/membership">
{children}
</Button>
) : (
children
);
};

View file

@ -9,6 +9,7 @@ type Props = {
disableInteractive?: boolean, disableInteractive?: boolean,
enterDelay?: number, enterDelay?: number,
title?: string | Node, title?: string | Node,
className?: string,
followCursor?: boolean, followCursor?: boolean,
placement?: string, // https://mui.com/api/tooltip/ placement?: string, // https://mui.com/api/tooltip/
}; };
@ -20,6 +21,7 @@ function Tooltip(props: Props) {
disableInteractive = true, disableInteractive = true,
enterDelay = 300, enterDelay = 300,
title, title,
className,
followCursor = false, followCursor = false,
placement = 'bottom', placement = 'bottom',
} = props; } = props;
@ -33,6 +35,7 @@ function Tooltip(props: Props) {
title={title} title={title}
followCursor={followCursor} followCursor={followCursor}
placement={placement} placement={placement}
classes={{ tooltip: className }}
> >
{children} {children}
</MUITooltip> </MUITooltip>

View file

@ -1,17 +1,17 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doSetClientSetting } from 'redux/actions/settings'; import { doSetClientSetting } from 'redux/actions/settings';
import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import HeaderMenuButtons from './view'; import HeaderMenuButtons from './view';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { doOpenModal } from 'redux/actions/app';
const select = (state) => ({ const select = (state) => ({
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
authenticated: selectUserVerifiedEmail(state), authenticated: selectUserVerifiedEmail(state),
automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED), automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED),
currentTheme: selectClientSetting(state, SETTINGS.THEME), currentTheme: selectClientSetting(state, SETTINGS.THEME),
user: selectUser(state), user: selectUser(state),
odyseeMembership: selectOdyseeMembershipName(state),
}); });
const perform = (dispatch) => ({ const perform = (dispatch) => ({
@ -19,6 +19,7 @@ const perform = (dispatch) => ({
if (automaticDarkModeEnabled) dispatch(doSetClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false)); if (automaticDarkModeEnabled) dispatch(doSetClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false));
dispatch(doSetClientSetting(SETTINGS.THEME, currentTheme === 'dark' ? 'light' : 'dark', true)); dispatch(doSetClientSetting(SETTINGS.THEME, currentTheme === 'dark' ? 'light' : 'dark', true));
}, },
doOpenModal: (id, params) => dispatch(doOpenModal(id, params)),
}); });
export default connect(select, perform)(HeaderMenuButtons); export default connect(select, perform)(HeaderMenuButtons);

View file

@ -1,7 +1,7 @@
// @flow // @flow
import 'scss/component/_header.scss'; import 'scss/component/_header.scss';
import { ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config'; import { ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
@ -18,25 +18,17 @@ type HeaderMenuButtonProps = {
currentTheme: string, currentTheme: string,
user: ?User, user: ?User,
handleThemeToggle: (boolean, string) => void, handleThemeToggle: (boolean, string) => void,
doOpenModal: (string, {}) => void,
odyseeMembership: string,
}; };
export default function HeaderMenuButtons(props: HeaderMenuButtonProps) { export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
const { const { authenticated, automaticDarkModeEnabled, currentTheme, user, handleThemeToggle, odyseeMembership } = props;
authenticated,
automaticDarkModeEnabled, const isOnMembershipPage = window.location.pathname === `/$/${PAGES.ODYSEE_MEMBERSHIP}`;
currentTheme,
activeChannelStakedLevel,
user,
handleThemeToggle,
} = props;
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui); const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const livestreamEnabled = Boolean( const livestreamEnabled = Boolean(ENABLE_NO_SOURCE_CLAIMS && user && !user.odysee_live_disabled);
ENABLE_NO_SOURCE_CLAIMS &&
user &&
!user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled)
);
return ( return (
<div className="header__buttons"> <div className="header__buttons">
@ -68,6 +60,10 @@ export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
<MenuList className="menu__list--header"> <MenuList className="menu__list--header">
<HeaderMenuLink page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} /> <HeaderMenuLink page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
{/* don't show upgrade button if on membership page or already have a membership */}
{!isOnMembershipPage && !odyseeMembership && (
<HeaderMenuLink page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
)}
<HeaderMenuLink page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} /> <HeaderMenuLink page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
<MenuItem className="menu__link" onSelect={() => handleThemeToggle(automaticDarkModeEnabled, currentTheme)}> <MenuItem className="menu__link" onSelect={() => handleThemeToggle(automaticDarkModeEnabled, currentTheme)}>

View file

@ -35,14 +35,13 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
) : ( ) : (
<MenuButton <MenuButton
aria-label={__('Your account')} aria-label={__('Your account')}
title={__('Your account')}
className={classnames('header__navigationItem', { className={classnames('header__navigationItem', {
'header__navigationItem--icon': !activeChannelUrl, 'header__navigationItem--icon': !activeChannelUrl,
'header__navigationItem--profilePic': activeChannelUrl, 'header__navigationItem--profilePic': activeChannelUrl,
})} })}
> >
{activeChannelUrl ? ( {activeChannelUrl ? (
<ChannelThumbnail uri={activeChannelUrl} hideTooltip small noLazyLoad /> <ChannelThumbnail uri={activeChannelUrl} hideTooltip small noLazyLoad showMemberBadge />
) : ( ) : (
<Icon size={18} icon={ICONS.ACCOUNT} aria-hidden /> <Icon size={18} icon={ICONS.ACCOUNT} aria-hidden />
)} )}
@ -57,6 +56,7 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
<HeaderMenuLink page={PAGES.CREATOR_DASHBOARD} icon={ICONS.ANALYTICS} name={__('Creator Analytics')} /> <HeaderMenuLink page={PAGES.CREATOR_DASHBOARD} icon={ICONS.ANALYTICS} name={__('Creator Analytics')} />
<HeaderMenuLink page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} /> <HeaderMenuLink page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} />
<HeaderMenuLink page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} /> <HeaderMenuLink page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} />
<HeaderMenuLink page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
<MenuItem onSelect={signOut}> <MenuItem onSelect={signOut}>
<div className="menu__link"> <div className="menu__link">

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream'; import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
import { doResolveUris } from 'redux/actions/claims'; import { doResolveUris } from 'redux/actions/claims';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri, selectClaimsByUri } from 'redux/selectors/claims';
import { doCommentList, doSuperChatList } from 'redux/actions/comments'; import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import { import {
selectTopLevelCommentsForUri, selectTopLevelCommentsForUri,
@ -9,6 +9,7 @@ import {
selectPinnedCommentsForUri, selectPinnedCommentsForUri,
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { selectThemePath } from 'redux/selectors/settings'; import { selectThemePath } from 'redux/selectors/settings';
import { doFetchUserMemberships } from 'redux/actions/user';
import LivestreamChatLayout from './view'; import LivestreamChatLayout from './view';
const select = (state, props) => { const select = (state, props) => {
@ -21,6 +22,7 @@ const select = (state, props) => {
pinnedComments: selectPinnedCommentsForUri(state, uri), pinnedComments: selectPinnedCommentsForUri(state, uri),
superChats: selectSuperChatsForUri(state, uri), superChats: selectSuperChatsForUri(state, uri),
theme: selectThemePath(state), theme: selectThemePath(state),
claimsByUri: selectClaimsByUri(state),
}; };
}; };
@ -28,6 +30,7 @@ const perform = {
doCommentList, doCommentList,
doSuperChatList, doSuperChatList,
doResolveUris, doResolveUris,
doFetchUserMemberships,
}; };
export default connect(select, perform)(LivestreamChatLayout); export default connect(select, perform)(LivestreamChatLayout);

View file

@ -18,6 +18,7 @@ import React from 'react';
import Yrbl from 'component/yrbl'; import Yrbl from 'component/yrbl';
import { getTipValues } from 'util/livestream'; import { getTipValues } from 'util/livestream';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
import useGetUserMemberships from 'effects/use-get-user-memberships';
export const VIEW_MODES = { export const VIEW_MODES = {
CHAT: 'chat', CHAT: 'chat',
@ -49,6 +50,8 @@ type Props = {
) => void, ) => void,
doResolveUris: (uris: Array<string>, cache: boolean) => void, doResolveUris: (uris: Array<string>, cache: boolean) => void,
doSuperChatList: (uri: string) => void, doSuperChatList: (uri: string) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function LivestreamChatLayout(props: Props) { export default function LivestreamChatLayout(props: Props) {
@ -68,6 +71,8 @@ export default function LivestreamChatLayout(props: Props) {
doCommentList, doCommentList,
doResolveUris, doResolveUris,
doSuperChatList, doSuperChatList,
doFetchUserMemberships,
claimsByUri,
} = props; } = props;
const isMobile = useIsMobile() && !isPopoutWindow; const isMobile = useIsMobile() && !isPopoutWindow;
@ -96,6 +101,22 @@ export default function LivestreamChatLayout(props: Props) {
superChatsByChronologicalOrder.sort((a, b) => b.timestamp - a.timestamp); superChatsByChronologicalOrder.sort((a, b) => b.timestamp - a.timestamp);
} }
// get commenter claim ids for checking premium status
const commenterClaimIds = commentsByChronologicalOrder.map(function(comment) {
return comment.channel_id;
});
// update premium status
const shouldFetchUserMemberships = true;
useGetUserMemberships(
shouldFetchUserMemberships,
commenterClaimIds,
claimsByUri,
doFetchUserMemberships,
[commentsByChronologicalOrder],
true,
);
const commentsToDisplay = const commentsToDisplay =
viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByChronologicalOrder; viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByChronologicalOrder;
const commentsLength = commentsToDisplay && commentsToDisplay.length; const commentsLength = commentsToDisplay && commentsToDisplay.length;

View file

@ -1,16 +1,27 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectStakedLevelForChannelUri, selectClaimForUri, selectMyClaimIdsRaw } from 'redux/selectors/claims'; import {
selectStakedLevelForChannelUri,
selectClaimForUri,
selectMyClaimIdsRaw,
selectClaimsByUri,
selectOdyseeMembershipForChannelId,
} from 'redux/selectors/claims';
import LivestreamComment from './view'; import LivestreamComment from './view';
const select = (state, props) => { const select = (state, props) => {
const { uri, comment } = props; const { uri, comment } = props;
const { channel_url: authorUri } = comment; const { channel_url: authorUri, channel_id: channelId } = comment;
return { return {
claim: selectClaimForUri(state, uri), claim: selectClaimForUri(state, uri),
stakedLevel: selectStakedLevelForChannelUri(state, authorUri), stakedLevel: selectStakedLevelForChannelUri(state, authorUri),
myChannelIds: selectMyClaimIdsRaw(state), myChannelIds: selectMyClaimIdsRaw(state),
claimsByUri: selectClaimsByUri(state),
odyseeMembership: selectOdyseeMembershipForChannelId(state, channelId),
}; };
}; };
export default connect(select)(LivestreamComment); const perform = {};
export default connect(select, perform)(LivestreamComment);

View file

@ -17,6 +17,7 @@ import Icon from 'component/common/icon';
import MarkdownPreview from 'component/common/markdown-preview'; import MarkdownPreview from 'component/common/markdown-preview';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import React from 'react'; import React from 'react';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
comment: Comment, comment: Comment,
@ -27,8 +28,10 @@ type Props = {
myChannelIds: ?Array<string>, myChannelIds: ?Array<string>,
stakedLevel: number, stakedLevel: number,
isMobile?: boolean, isMobile?: boolean,
odyseeMembership: string,
handleDismissPin?: () => void, handleDismissPin?: () => void,
restoreScrollPos?: () => void, restoreScrollPos?: () => void,
claimsByUri: { [string]: any },
}; };
export default function LivestreamComment(props: Props) { export default function LivestreamComment(props: Props) {
@ -42,6 +45,7 @@ export default function LivestreamComment(props: Props) {
isMobile, isMobile,
handleDismissPin, handleDismissPin,
restoreScrollPos, restoreScrollPos,
odyseeMembership,
} = props; } = props;
const { const {
@ -98,10 +102,6 @@ export default function LivestreamComment(props: Props) {
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />} {supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestreamComment__info"> <div className="livestreamComment__info">
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} size={16} />}
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
<Button <Button
className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })} className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })}
target="_blank" target="_blank"
@ -117,6 +117,11 @@ export default function LivestreamComment(props: Props) {
</span> </span>
)} )}
{isGlobalMod && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_ADMIN} size={16} />}
{isModerator && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
<PremiumBadge membership={odyseeMembership} />
{/* Use key to force timestamp update */} {/* Use key to force timestamp update */}
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds /> <DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
@ -135,6 +140,7 @@ export default function LivestreamComment(props: Props) {
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
disableTimestamps disableTimestamps
setUserMention={setUserMention} setUserMention={setUserMention}
hasMembership={odyseeMembership}
/> />
)} )}
</div> </div>

Binary file not shown.

After

(image error) Size: 218 KiB

Binary file not shown.

After

(image error) Size: 13 KiB

Binary file not shown.

After

(image error) Size: 12 KiB

View file

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import {
selectTitleForUri,
makeSelectClaimForUri,
selectClaimIsMineForUri,
selectFetchingMyChannels,
} from 'redux/selectors/claims';
import { selectClientSetting } from 'redux/selectors/settings';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet';
import { withRouter } from 'react-router';
import * as SETTINGS from 'constants/settings';
import WalletSendTip from './view';
const select = (state, props) => ({
activeChannelClaim: selectActiveChannelClaim(state),
balance: selectBalance(state),
claim: makeSelectClaimForUri(props.uri, false)(state),
claimIsMine: selectClaimIsMineForUri(state, props.uri),
fetchingChannels: selectFetchingMyChannels(state),
incognito: selectIncognito(state),
instantTipEnabled: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_ENABLED),
instantTipMax: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_MAX),
isPending: selectIsSendingSupport(state),
title: selectTitleForUri(state, props.uri),
});
const perform = (dispatch) => ({
doOpenModal,
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
});
export default withRouter(connect(select, perform)(WalletSendTip));

Binary file not shown.

After

(image error) Size: 12 KiB

View file

@ -0,0 +1,139 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import Icon from 'component/common/icon';
import Button from 'component/button';
import React from 'react';
import AstronautAndFriends from './astronaut_n_friends.png';
import BadgePremium from './badge_premium.png';
import BadgePremiumPlus from './badge_premium-plus.png';
import OdyseePremium from './odysee_premium.png';
import I18nMessage from 'component/i18nMessage';
type Props = {
pageLocation: string,
currencyToUse: string,
};
export default function MembershipSplash(props: Props) {
const { pageLocation, currencyToUse } = props;
const premiumDisplayAmounts = {
eur: '€0.89',
usd: '99¢',
};
const premiumPlusDisplayAmounts = {
eur: '€2.68',
usd: '$2.99',
};
// const logo = <Icon className="header__logo" icon={ICONS.ODYSEE_WHITE_TEXT} />;
const earlyAcessInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.EARLY_ACCESS} />
{__('Exclusive and early access to features')}
</div>
);
const badgeInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.MEMBER_BADGE} />
{__('Badge on profile')}
</div>
);
const noAdsInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.NO_ADS} />
{__('No ads')}
</div>
);
return (
<div className="membership-splash">
<div className="membership-splash__banner">
<img src={AstronautAndFriends} />
<section className="membership-splash__title">
<section>
<img src={OdyseePremium} />
</section>
<section>
<I18nMessage
tokens={{ early_access: <b>{__('early access')}</b>, site_wide_badge: <b>{__('site-wide badge')}</b> }}
>
Get %early_access% features and a %site_wide_badge%
</I18nMessage>
</section>
</section>
</div>
<div className="membership-splash__info-wrapper">
<div className="membership-splash__info">
<I18nMessage>
Creating a revolutionary video platform for everyone is something we're proud to be doing, but it isn't
something that can happen without support. If you believe in Odysee's mission, please consider becoming a
Premium member. As a Premium member, you'll be helping us build the best platform in the universe and we'll
give you some cool perks!
</I18nMessage>
</div>
<div className="membership-splash__info">
<section className="membership-splash__info-header">
<div className="membership-splash__info-price">
<img src={BadgePremium} />
<section>
<I18nMessage
tokens={{
date_range: <div className="membership-splash__info-range">{__('A MONTH')}</div>,
currencyToUseString: premiumDisplayAmounts[currencyToUse],
}}
>
%currencyToUseString% %date_range%
</I18nMessage>
</section>
</div>
</section>
{badgeInfo}
{earlyAcessInfo}
<div className="membership-splash__info-button">
<Button
button="primary"
label={__('Join')}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}?interval=year&plan=Premium&pageLocation=${pageLocation}`}
/>
</div>
</div>
<div className="membership-splash__info">
<section className="membership-splash__info-header">
<div className="membership-splash__info-price">
<img src={BadgePremiumPlus} />
<section>
{premiumPlusDisplayAmounts[currencyToUse]}
<div className="membership-splash__info-range">{__('A MONTH')}</div>
</section>
</div>
</section>
{badgeInfo}
{earlyAcessInfo}
{noAdsInfo}
<div className="membership-splash__info-button">
<Button
button="primary"
label={__('Join')}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}?interval=year&plan=Premium%2b&pageLocation=${pageLocation}&`}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -32,7 +32,7 @@ import {
} from 'redux/selectors/app'; } from 'redux/selectors/app';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content'; import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import { selectUser } from 'redux/selectors/user'; import { selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import PublishForm from './view'; import PublishForm from './view';
const select = (state) => { const select = (state) => {
@ -65,6 +65,7 @@ const select = (state) => {
activeChannelStakedLevel: selectActiveChannelStakedLevel(state), activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
isClaimingInitialRewards: selectIsClaimingInitialRewards(state), isClaimingInitialRewards: selectIsClaimingInitialRewards(state),
hasClaimedInitialRewards: selectHasClaimedInitialRewards(state), hasClaimedInitialRewards: selectHasClaimedInitialRewards(state),
odyseeMembership: selectOdyseeMembershipName(state),
}; };
}; };

View file

@ -97,6 +97,7 @@ type Props = {
isClaimingInitialRewards: boolean, isClaimingInitialRewards: boolean,
claimInitialRewards: () => void, claimInitialRewards: () => void,
hasClaimedInitialRewards: boolean, hasClaimedInitialRewards: boolean,
odyseeMembership: string,
}; };
function PublishForm(props: Props) { function PublishForm(props: Props) {
@ -138,6 +139,7 @@ function PublishForm(props: Props) {
isClaimingInitialRewards, isClaimingInitialRewards,
claimInitialRewards, claimInitialRewards,
hasClaimedInitialRewards, hasClaimedInitialRewards,
odyseeMembership,
} = props; } = props;
const inEditMode = Boolean(editingURI); const inEditMode = Boolean(editingURI);
@ -146,11 +148,15 @@ function PublishForm(props: Props) {
const TYPE_PARAM = 'type'; const TYPE_PARAM = 'type';
const uploadType = urlParams.get(TYPE_PARAM); const uploadType = urlParams.get(TYPE_PARAM);
const _uploadType = uploadType && uploadType.toLowerCase(); const _uploadType = uploadType && uploadType.toLowerCase();
const userHasEnoughLBCForStreaming = activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM;
const enableLivestream = const enableLivestream =
ENABLE_NO_SOURCE_CLAIMS && ENABLE_NO_SOURCE_CLAIMS &&
user && user &&
!user.odysee_live_disabled && !user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled); (userHasEnoughLBCForStreaming || user.odysee_live_enabled || odyseeMembership);
// $FlowFixMe // $FlowFixMe
const AVAILABLE_MODES = Object.values(PUBLISH_MODES).filter((mode) => { const AVAILABLE_MODES = Object.values(PUBLISH_MODES).filter((mode) => {
// $FlowFixMe // $FlowFixMe

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri } from 'redux/selectors/claims';
import { doFetchRecommendedContent } from 'redux/actions/search'; import { doFetchRecommendedContent } from 'redux/actions/search';
import { selectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { selectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import RecommendedContent from './view'; import RecommendedContent from './view';
const select = (state, props) => { const select = (state, props) => {
@ -14,7 +14,7 @@ const select = (state, props) => {
recommendedContentUris, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching: selectIsSearching(state), isSearching: selectIsSearching(state),
isAuthenticated: selectUserVerifiedEmail(state), userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}; };
}; };

View file

@ -23,8 +23,10 @@ type Props = {
nextRecommendedUri: string, nextRecommendedUri: string,
isSearching: boolean, isSearching: boolean,
doFetchRecommendedContent: (string) => void, doFetchRecommendedContent: (string) => void,
isAuthenticated: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
claimId: string,
metadata: any,
userHasPremiumPlus: boolean,
}; };
export default React.memo<Props>(function RecommendedContent(props: Props) { export default React.memo<Props>(function RecommendedContent(props: Props) {
@ -34,12 +36,12 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
recommendedContentUris, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching, isSearching,
isAuthenticated,
claim, claim,
userHasPremiumPlus,
} = props; } = props;
const claimId: ?string = claim && claim.claim_id; const claimId: ?string = claim && claim.claim_id;
const injectAds = SHOW_ADS && IS_WEB && !isAuthenticated; const injectAds = SHOW_ADS && IS_WEB && !userHasPremiumPlus;
function claimContainsBlockedWords(claim: ?StreamClaim) { function claimContainsBlockedWords(claim: ?StreamClaim) {
if (BLOCKED_WORDS) { if (BLOCKED_WORDS) {
@ -177,7 +179,6 @@ function areEqual(prevProps: Props, nextProps: Props) {
if ( if (
a.uri !== b.uri || a.uri !== b.uri ||
a.nextRecommendedUri !== b.nextRecommendedUri || a.nextRecommendedUri !== b.nextRecommendedUri ||
a.isAuthenticated !== b.isAuthenticated ||
a.isSearching !== b.isSearching || a.isSearching !== b.isSearching ||
(a.recommendedContentUris && !b.recommendedContentUris) || (a.recommendedContentUris && !b.recommendedContentUris) ||
(!a.recommendedContentUris && b.recommendedContentUris) || (!a.recommendedContentUris && b.recommendedContentUris) ||

View file

@ -72,6 +72,9 @@ const LiveStreamSetupPage = lazyImport(() => import('page/livestreamSetup' /* we
const LivestreamCurrentPage = lazyImport(() => const LivestreamCurrentPage = lazyImport(() =>
import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */) import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */)
); );
const OdyseeMembershipPage = lazyImport(() =>
import('page/odyseeMembership' /* webpackChunkName: "odyseeMembership" */)
);
const OwnComments = lazyImport(() => import('page/ownComments' /* webpackChunkName: "ownComments" */)); const OwnComments = lazyImport(() => import('page/ownComments' /* webpackChunkName: "ownComments" */));
const PasswordResetPage = lazyImport(() => import('page/passwordReset' /* webpackChunkName: "passwordReset" */)); const PasswordResetPage = lazyImport(() => import('page/passwordReset' /* webpackChunkName: "passwordReset" */));
const PasswordSetPage = lazyImport(() => import('page/passwordSet' /* webpackChunkName: "passwordSet" */)); const PasswordSetPage = lazyImport(() => import('page/passwordSet' /* webpackChunkName: "passwordSet" */));
@ -365,6 +368,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} /> <PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} /> <PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} />
<PrivateRoute {...props} path={`/$/${PAGES.ODYSEE_MEMBERSHIP}`} component={OdyseeMembershipPage} />
<Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} /> <Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} />

View file

@ -1,15 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doFetchLastActiveSubs } from 'redux/actions/subscriptions'; import { doFetchLastActiveSubs } from 'redux/actions/subscriptions';
import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { selectLastActiveSubscriptions, selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectLastActiveSubscriptions, selectSubscriptions } from 'redux/selectors/subscriptions';
import { doClearClaimSearch } from 'redux/actions/claims'; import { doClearClaimSearch } from 'redux/actions/claims';
import { doClearPurchasedUriSuccess } from 'redux/actions/file'; import { doClearPurchasedUriSuccess } from 'redux/actions/file';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { selectHomepageData, selectWildWestDisabled } from 'redux/selectors/settings'; import { selectHomepageData, selectWildWestDisabled } from 'redux/selectors/settings';
import { doSignOut } from 'redux/actions/app'; import { doSignOut } from 'redux/actions/app';
import { selectUnseenNotificationCount } from 'redux/selectors/notifications'; import { selectUnseenNotificationCount } from 'redux/selectors/notifications';
import { selectPurchaseUriSuccess } from 'redux/selectors/claims'; import { selectPurchaseUriSuccess, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import SideNavigation from './view'; import SideNavigation from './view';
@ -22,8 +21,9 @@ const select = (state) => ({
unseenCount: selectUnseenNotificationCount(state), unseenCount: selectUnseenNotificationCount(state),
user: selectUser(state), user: selectUser(state),
homepageData: selectHomepageData(state), homepageData: selectHomepageData(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
wildWestDisabled: selectWildWestDisabled(state), wildWestDisabled: selectWildWestDisabled(state),
odyseeMembership: selectOdyseeMembershipName(state),
odyseeMembershipByUri: (uri) => selectOdyseeMembershipForUri(state, uri),
}); });
export default connect(select, { export default connect(select, {

View file

@ -15,7 +15,8 @@ import I18nMessage from 'component/i18nMessage';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { useIsMobile, useIsLargeScreen, isTouch } from 'effects/use-screensize'; import { useIsMobile, useIsLargeScreen, isTouch } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage'; import { GetLinksData } from 'util/buildHomepage';
import { DOMAIN, ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config'; import { DOMAIN, ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import PremiumBadge from 'component/common/premium-badge';
const touch = isTouch(); const touch = isTouch();
@ -70,6 +71,13 @@ const PLAYLISTS = {
hideForUnauth: true, hideForUnauth: true,
}; };
const PREMIUM = {
title: 'Premium',
link: `/$/${PAGES.ODYSEE_MEMBERSHIP}`,
icon: ICONS.UPGRADE,
hideForUnauth: true,
};
const UNAUTH_LINKS: Array<SideNavLink> = [ const UNAUTH_LINKS: Array<SideNavLink> = [
{ {
title: 'Log In', title: 'Log In',
@ -118,9 +126,10 @@ type Props = {
doClearPurchasedUriSuccess: () => void, doClearPurchasedUriSuccess: () => void,
user: ?User, user: ?User,
homepageData: any, homepageData: any,
activeChannelStakedLevel: number,
wildWestDisabled: boolean, wildWestDisabled: boolean,
doClearClaimSearch: () => void, doClearClaimSearch: () => void,
odyseeMembership: string,
odyseeMembershipByUri: (uri: string) => string,
doFetchLastActiveSubs: (force?: boolean, count?: number) => void, doFetchLastActiveSubs: (force?: boolean, count?: number) => void,
}; };
@ -140,9 +149,10 @@ function SideNavigation(props: Props) {
homepageData, homepageData,
user, user,
followedTags, followedTags,
activeChannelStakedLevel,
wildWestDisabled, wildWestDisabled,
doClearClaimSearch, doClearClaimSearch,
odyseeMembership,
odyseeMembershipByUri,
doFetchLastActiveSubs, doFetchLastActiveSubs,
} = props; } = props;
@ -228,12 +238,7 @@ function SideNavigation(props: Props) {
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui); const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const isAuthenticated = Boolean(email); const isAuthenticated = Boolean(email);
const livestreamEnabled = Boolean( const livestreamEnabled = Boolean(ENABLE_NO_SOURCE_CLAIMS && user && !user.odysee_live_disabled);
ENABLE_NO_SOURCE_CLAIMS &&
user &&
!user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled)
);
const [pulseLibrary, setPulseLibrary] = React.useState(false); const [pulseLibrary, setPulseLibrary] = React.useState(false);
const [expandTags, setExpandTags] = React.useState(false); const [expandTags, setExpandTags] = React.useState(false);
@ -325,7 +330,11 @@ function SideNavigation(props: Props) {
</li> </li>
)} )}
{displayedSubscriptions.map((subscription) => ( {displayedSubscriptions.map((subscription) => (
<SubscriptionListItem key={subscription.uri} subscription={subscription} /> <SubscriptionListItem
key={subscription.uri}
subscription={subscription}
odyseeMembershipByUri={odyseeMembershipByUri}
/>
))} ))}
{!!subscriptionFilter && !displayedSubscriptions.length && ( {!!subscriptionFilter && !displayedSubscriptions.length && (
<li> <li>
@ -494,6 +503,7 @@ function SideNavigation(props: Props) {
{getLink(getHomeButton(doClearClaimSearch))} {getLink(getHomeButton(doClearClaimSearch))}
{getLink(RECENT_FROM_FOLLOWING)} {getLink(RECENT_FROM_FOLLOWING)}
{getLink(PLAYLISTS)} {getLink(PLAYLISTS)}
{!odyseeMembership && getLink(PREMIUM)}
</ul> </ul>
<ul <ul
@ -533,8 +543,17 @@ function SideNavigation(props: Props) {
); );
} }
function SubscriptionListItem({ subscription }: { subscription: Subscription }) { type SubItemProps = {
subscription: Subscription,
odyseeMembershipByUri: (uri: string) => string,
}
function SubscriptionListItem(props: SubItemProps) {
const { subscription, odyseeMembershipByUri } = props;
const { uri, channelName } = subscription; const { uri, channelName } = subscription;
const membership = odyseeMembershipByUri(uri);
return ( return (
<li className="navigation-link__wrapper navigation__subscription"> <li className="navigation-link__wrapper navigation__subscription">
<Button <Button
@ -547,6 +566,7 @@ function SubscriptionListItem({ subscription }: { subscription: Subscription })
<ClaimPreviewTitle uri={uri} /> <ClaimPreviewTitle uri={uri} />
<span dir="auto" className="channel-name"> <span dir="auto" className="channel-name">
{channelName} {channelName}
<PremiumBadge membership={membership} />
</span> </span>
</div> </div>
</Button> </Button>

View file

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import TextareaSuggestionsItem from './view'; import TextareaSuggestionsItem from './view';
import { formatLbryChannelName } from 'util/url'; import { formatLbryChannelName } from 'util/url';
import { getClaimTitle } from 'util/claim'; import { getClaimTitle } from 'util/claim';
@ -12,6 +12,7 @@ const select = (state, props) => {
return { return {
claimLabel: claim && formatLbryChannelName(claim.canonical_url), claimLabel: claim && formatLbryChannelName(claim.canonical_url),
claimTitle: claim && getClaimTitle(claim), claimTitle: claim && getClaimTitle(claim),
odyseeMembershipByUri: selectOdyseeMembershipForUri(state, uri),
}; };
}; };

View file

@ -1,16 +1,18 @@
// @flow // @flow
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import React from 'react'; import React from 'react';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
claimLabel?: string, claimLabel?: string,
claimTitle?: string, claimTitle?: string,
emote?: any, emote?: any,
uri?: string, uri?: string,
odyseeMembershipByUri: ?string,
}; };
export default function TextareaSuggestionsItem(props: Props) { export default function TextareaSuggestionsItem(props: Props) {
const { claimLabel, claimTitle, emote, uri, ...autocompleteProps } = props; const { claimLabel, claimTitle, emote, uri, odyseeMembershipByUri, ...autocompleteProps } = props;
if (emote) { if (emote) {
const { name: value, url, unicode } = emote; const { name: value, url, unicode } = emote;
@ -37,7 +39,10 @@ export default function TextareaSuggestionsItem(props: Props) {
<div className="textarea-suggestion__label"> <div className="textarea-suggestion__label">
<span className="textarea-suggestion__title">{claimTitle || value}</span> <span className="textarea-suggestion__title">{claimTitle || value}</span>
<span className="textarea-suggestion__value">{value}</span> <span className="textarea-suggestion__value">
{value}
<PremiumBadge membership={odyseeMembershipByUri} />
</span>
</div> </div>
</div> </div>
); );

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { normalizeURI } from 'util/lbryURI'; import { normalizeURI } from 'util/lbryURI';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { selectIsUriResolving, selectClaimForUri } from 'redux/selectors/claims'; import { selectIsUriResolving, selectClaimForUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import UriIndicator from './view'; import UriIndicator from './view';
const select = (state, props) => { const select = (state, props) => {
@ -14,6 +14,7 @@ const select = (state, props) => {
claim: selectClaimForUri(state, props.uri), claim: selectClaimForUri(state, props.uri),
isResolvingUri: selectIsUriResolving(state, props.uri), isResolvingUri: selectIsUriResolving(state, props.uri),
uri, uri,
odyseeMembership: selectOdyseeMembershipForUri(state, props.uri),
}; };
}; };

View file

@ -3,6 +3,7 @@ import type { Node } from 'react';
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import Button from 'component/button'; import Button from 'component/button';
import PremiumBadge from 'component/common/premium-badge';
import { stripLeadingAtSign } from 'util/string'; import { stripLeadingAtSign } from 'util/string';
type ChannelInfo = { uri: string, name: string, title: string }; type ChannelInfo = { uri: string, name: string, title: string };
@ -17,10 +18,13 @@ type Props = {
inline?: boolean, inline?: boolean,
showAtSign?: boolean, showAtSign?: boolean,
className?: string, className?: string,
showMemberBadge?: boolean,
children: ?Node, // to allow for other elements to be nested within the UriIndicator (commit: 1e82586f). children: ?Node, // to allow for other elements to be nested within the UriIndicator (commit: 1e82586f).
// --- redux --- // --- redux ---
claim: ?Claim, claim: ?Claim,
isResolvingUri: boolean, isResolvingUri: boolean,
odyseeMembership: string,
comment?: boolean,
resolveUri: (string) => void, resolveUri: (string) => void,
}; };
@ -90,6 +94,9 @@ class UriIndicator extends React.PureComponent<Props> {
hideAnonymous = false, hideAnonymous = false,
showAtSign, showAtSign,
className, className,
odyseeMembership,
comment,
showMemberBadge = true,
} = this.props; } = this.props;
if (!channelInfo && !claim) { if (!channelInfo && !claim) {
@ -119,7 +126,8 @@ class UriIndicator extends React.PureComponent<Props> {
const inner = ( const inner = (
<span dir="auto" className={classnames('channel-name', { 'channel-name--inline': inline })}> <span dir="auto" className={classnames('channel-name', { 'channel-name--inline': inline })}>
{showAtSign ? channelName : stripLeadingAtSign(channelTitle)} <p>{showAtSign ? channelName : stripLeadingAtSign(channelTitle)}</p>
{!comment && showMemberBadge && <PremiumBadge membership={odyseeMembership} />}
</span> </span>
); );

View file

@ -1,10 +1,15 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims'; import { selectClaimForUri, selectIsUriResolving, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import WunderbarSuggestion from './view'; import WunderbarSuggestion from './view';
const select = (state, props) => ({ const select = (state, props) => {
claim: selectClaimForUri(state, props.uri), const { uri } = props;
isResolvingUri: selectIsUriResolving(state, props.uri),
}); return {
claim: selectClaimForUri(state, uri),
isResolvingUri: selectIsUriResolving(state, uri),
odyseeMembershipByUri: selectOdyseeMembershipForUri(state, uri),
};
};
export default connect(select)(WunderbarSuggestion); export default connect(select)(WunderbarSuggestion);

View file

@ -6,15 +6,17 @@ import FileThumbnail from 'component/fileThumbnail';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import FileProperties from 'component/previewOverlayProperties'; import FileProperties from 'component/previewOverlayProperties';
import ClaimProperties from 'component/claimProperties'; import ClaimProperties from 'component/claimProperties';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
claim: ?Claim, claim: ?Claim,
uri: string, uri: string,
isResolvingUri: boolean, isResolvingUri: boolean,
odyseeMembershipByUri: ?string,
}; };
export default function WunderbarSuggestion(props: Props) { export default function WunderbarSuggestion(props: Props) {
const { claim, uri, isResolvingUri } = props; const { claim, uri, isResolvingUri, odyseeMembershipByUri } = props;
if (isResolvingUri) { if (isResolvingUri) {
return ( return (
@ -61,6 +63,7 @@ export default function WunderbarSuggestion(props: Props) {
<div className="wunderbar__suggestion-title">{claim.value.title}</div> <div className="wunderbar__suggestion-title">{claim.value.title}</div>
<div className="wunderbar__suggestion-name"> <div className="wunderbar__suggestion-name">
{isChannel ? claim.name : (claim.signing_channel && claim.signing_channel.name) || __('Anonymous')} {isChannel ? claim.name : (claim.signing_channel && claim.signing_channel.name) || __('Anonymous')}
<PremiumBadge membership={odyseeMembershipByUri} />
</div> </div>
</span> </span>
</div> </div>

View file

@ -298,6 +298,8 @@ export const USER_SET_REFERRER_SUCCESS = 'USER_SET_REFERRER_SUCCESS';
export const USER_SET_REFERRER_FAILURE = 'USER_SET_REFERRER_FAILURE'; export const USER_SET_REFERRER_FAILURE = 'USER_SET_REFERRER_FAILURE';
export const USER_SET_REFERRER_RESET = 'USER_SET_REFERRER_RESET'; export const USER_SET_REFERRER_RESET = 'USER_SET_REFERRER_RESET';
export const USER_EMAIL_VERIFY_RETRY = 'USER_EMAIL_VERIFY_RETRY'; export const USER_EMAIL_VERIFY_RETRY = 'USER_EMAIL_VERIFY_RETRY';
export const ADD_ODYSEE_MEMBERSHIP_DATA = 'ADD_ODYSEE_MEMBERSHIP_DATA';
export const ADD_CLAIMIDS_MEMBERSHIP_DATA = 'ADD_CLAIMIDS_MEMBERSHIP_DATA';
// Rewards // Rewards
export const FETCH_REWARDS_STARTED = 'FETCH_REWARDS_STARTED'; export const FETCH_REWARDS_STARTED = 'FETCH_REWARDS_STARTED';

View file

@ -178,6 +178,7 @@ export const CONTENT = 'Content';
export const STAR = 'star'; export const STAR = 'star';
export const MUSIC = 'MusicCategory'; export const MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod'; export const BADGE_MOD = 'BadgeMod';
export const BADGE_ADMIN = 'BadgeAdmin';
export const BADGE_STREAMER = 'BadgeStreamer'; export const BADGE_STREAMER = 'BadgeStreamer';
export const REPLAY = 'Replay'; export const REPLAY = 'Replay';
export const REPEAT = 'Repeat'; export const REPEAT = 'Repeat';
@ -195,6 +196,12 @@ export const ODYSEE_LOGO = 'OdyseeLogo';
export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText'; export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText';
export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText'; export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText';
export const FEATURED = 'Featured'; export const FEATURED = 'Featured';
export const EARLY_ACCESS = 'EarlyAccess';
export const MEMBER_BADGE = 'MemberBadge';
export const NO_ADS = 'NoAds';
export const PREMIUM = 'Premium';
export const PREMIUM_PLUS = 'PremiumPlus';
export const UPGRADE = 'Upgrade';
export const DISMISS_ALL = 'DismissAll'; export const DISMISS_ALL = 'DismissAll';
export const SUBMIT = 'Submit'; export const SUBMIT = 'Submit';
export const FILTERED_BY_LANG = 'FilteredByLang'; export const FILTERED_BY_LANG = 'FilteredByLang';

View file

@ -47,3 +47,5 @@ export const COLLECTION_ADD = 'collection_add';
export const COLLECTION_DELETE = 'collection_delete'; export const COLLECTION_DELETE = 'collection_delete';
export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD'; export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD';
export const CONFIRM_REMOVE_COMMENT = 'CONFIRM_REMOVE_COMMENT'; export const CONFIRM_REMOVE_COMMENT = 'CONFIRM_REMOVE_COMMENT';
export const CONFIRM_ODYSEE_MEMBERSHIP = 'CONFIRM_ODYSEE_MEMBERSHIP';
export const MEMBERSHIP_SPLASH = 'MEMBERSHIP_SPLASH';

View file

@ -86,4 +86,5 @@ exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live'; exports.LIVESTREAM_CURRENT = 'live';
exports.GENERAL = 'general'; exports.GENERAL = 'general';
exports.LIST = 'list'; exports.LIST = 'list';
exports.ODYSEE_MEMBERSHIP = 'membership';
exports.POPOUT = 'popout'; exports.POPOUT = 'popout';

View file

@ -1,10 +1,17 @@
// @flow // @flow
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
/**
*
* @param {boolean} shouldFetch - Whether to get the views, not needed for some pages
* @param {array} uris - Array of the LBRY uris of content to fetch views for
* @param {object} claimsByUri - Function to get claimIds from claim uris
* @param {function} doFetchViewCount - Get views account per a string of comma separated Claim Ids
*/
export default function useFetchViewCount( export default function useFetchViewCount(
shouldFetch: ?boolean, shouldFetch: ?boolean,
uris: Array<string>, uris: Array<string>,
claimsByUri: any, claimsByUri: {},
doFetchViewCount: (string) => void doFetchViewCount: (string) => void
) { ) {
const [fetchedUris, setFetchedUris] = useState([]); const [fetchedUris, setFetchedUris] = useState([]);

View file

@ -0,0 +1,62 @@
// @flow
import { useState, useEffect } from 'react';
import { getChannelFromClaim } from 'util/claim';
export default function useGetUserMemberships(
shouldFetchUserMemberships: ?boolean,
arrayOfContentUris: ?Array<string>,
convertClaimUrlsToIds: any,
doFetchUserMemberships: (string) => void, // fetch membership values and save in redux
dependency?: any,
alreadyClaimIds?: boolean,
) {
const [userMemberships, setUserMemberships] = useState([]);
useEffect(() => {
if (shouldFetchUserMemberships && arrayOfContentUris && arrayOfContentUris.length > 0) {
const urisToFetch = arrayOfContentUris;
let claimIds;
if (!alreadyClaimIds) {
claimIds = urisToFetch.map((uri) => {
// get claim id from array
const claimUrlsToId = convertClaimUrlsToIds[uri];
if (claimUrlsToId) {
const { claim_id: claimId } = getChannelFromClaim(claimUrlsToId) || {};
return claimId;
}
});
} else {
claimIds = arrayOfContentUris;
}
const dedupedChannelIds = [...new Set(claimIds)];
const channelClaimIdsToCheck = dedupedChannelIds.filter(
// not in fetched claims but exists in array
(claimId) => claimId && !userMemberships.includes(claimId)
);
const channelsToFetch = channelClaimIdsToCheck.filter(
// not in fetched claims but exists in array
(uri) => uri && !userMemberships.includes(uri)
);
const commaSeparatedStringOfIds = channelsToFetch.join(',');
if (channelsToFetch && channelsToFetch.length > 0) {
// hit membership/check and save it in redux
const combinedArray = [...userMemberships, ...channelsToFetch];
setUserMemberships(combinedArray);
if (doFetchUserMemberships) {
doFetchUserMemberships(commaSeparatedStringOfIds);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependency || [arrayOfContentUris]);
}

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import { doToast } from 'redux/actions/notifications';
import ModalConfirmOdyseeMembership from './view';
const perform = (dispatch) => ({
closeModal: () => dispatch(doHideModal()),
doToast: (params) => dispatch(doToast(params)),
});
export default connect(null, perform)(ModalConfirmOdyseeMembership);

View file

@ -0,0 +1,169 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import Card from 'component/common/card';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
type Props = {
closeModal: () => void,
paymentMethodId: string,
setAsConfirmingCard: () => void, // ?
hasMembership: boolean, // user already has purchased --> invoke Cancel then
membershipId: string,
populateMembershipData: () => void,
userChannelClaimId: string,
userChannelName: string,
priceId: string,
purchaseString: string,
plan: string,
setMembershipOptions: (any) => void,
doToast: ({ message: string }) => void,
updateUserOdyseeMembershipStatus: ({}) => void,
user: ?User,
};
export default function ConfirmOdyseeMembershipPurchase(props: Props) {
const {
closeModal,
membershipId,
populateMembershipData,
userChannelClaimId,
userChannelName,
hasMembership,
priceId,
purchaseString,
plan,
setMembershipOptions,
doToast,
updateUserOdyseeMembershipStatus,
user,
} = props;
const [waitingForBackend, setWaitingForBackend] = React.useState();
const [statusText, setStatusText] = React.useState();
async function purchaseMembership() {
try {
setWaitingForBackend(true);
setStatusText(__('Completing your purchase...'));
// show the memberships the user is subscribed to
await Lbryio.call(
'membership',
'buy',
{
environment: stripeEnvironment,
membership_id: membershipId,
channel_id: userChannelClaimId,
channel_name: userChannelName,
price_id: priceId,
},
'post'
);
// cleary query params
// $FlowFixMe
let newURL = location.href.split('?')[0];
window.history.pushState('object', document.title, newURL);
setStatusText(__('Membership was successful'));
// populate the new data and update frontend
await populateMembershipData();
// clear the other membership options after making a purchase
setMembershipOptions(false);
if (user) updateUserOdyseeMembershipStatus(user);
closeModal();
} catch (err) {
const errorMessage = err.message;
const subscriptionFailedBackendError = 'failed to create subscription with default card';
// wait a bit to show the message so it's not jarring for the user
let errorMessageTimeout = 1150;
// don't do an error delay if there's already a network error
if (errorMessage === subscriptionFailedBackendError) {
errorMessageTimeout = 0;
}
setTimeout(function () {
const genericErrorMessage = __(
"Sorry, your purchase wasn't able to completed. Please contact support for possible next steps"
);
doToast({
message: genericErrorMessage,
isError: true,
});
closeModal();
}, errorMessageTimeout);
console.log(err);
}
}
// Cancel
async function cancelMembership() {
try {
setWaitingForBackend(true);
setStatusText(__('Canceling your membership...'));
// show the memberships the user is subscribed to
await Lbryio.call(
'membership',
'cancel',
{
environment: stripeEnvironment,
membership_id: membershipId,
},
'post'
);
setStatusText(__('Membership successfully canceled'));
// populate the new data and update frontend
await populateMembershipData();
closeModal();
} catch (err) {
console.log(err);
}
}
return (
<Modal ariaHideApp={false} isOpen contentLabel={'Confirm Membership Purchase'} type="card" onAborted={closeModal}>
<Card
className="stripe__confirm-remove-membership"
title={hasMembership ? __('Confirm Membership Cancellation') : __(`Confirm %plan% Membership`, { plan })}
subtitle={purchaseString}
actions={
<div className="section__actions">
{!waitingForBackend ? (
<>
<Button
className="stripe__confirm-remove-card"
button="primary"
icon={ICONS.FINANCE}
label={hasMembership ? __('Confirm Cancellation') : __('Confirm Purchase')}
onClick={() => (hasMembership ? cancelMembership() : purchaseMembership())}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />
</>
) : (
<h1 style={{ fontSize: '18px' }}>{statusText}</h1>
)}
</div>
}
/>
</Modal>
);
}

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ModalMembershipSplash from './view';
const perform = (dispatch) => ({
closeModal: () => dispatch(doHideModal()),
});
export default connect(null, perform)(ModalMembershipSplash);

View file

@ -0,0 +1,25 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import MembershipSplash from 'component/membershipSplash';
type Props = {
closeModal: () => void,
uri: string,
claimIsMine: boolean,
isSupport: boolean,
};
class ModalSendTip extends React.PureComponent<Props> {
render() {
const { closeModal, uri, claimIsMine } = this.props;
return (
<Modal onAborted={closeModal} isOpen type="card">
<MembershipSplash uri={uri} claimIsMine={claimIsMine} onCancel={closeModal} />
</Modal>
);
}
}
export default ModalSendTip;

View file

@ -65,6 +65,12 @@ const ModalPublishPreview = lazyImport(() =>
import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */) import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */)
); );
const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */)); const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */));
const ModalConfirmOdyseeMembership = lazyImport(() =>
import('modal/modalConfirmOdyseeMembership' /* webpackChunkName: "modalConfirmOdyseeMembership" */)
);
const OdyseeMembershipSplash = lazyImport(() =>
import('modal/modalMembershipSplash' /* webpackChunkName: "modalMembershipSplash" */)
);
const ModalRemoveComment = lazyImport(() => const ModalRemoveComment = lazyImport(() =>
import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */) import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)
); );
@ -126,6 +132,8 @@ function getModal(id) {
return ModalPhoneCollection; return ModalPhoneCollection;
case MODALS.SEND_TIP: case MODALS.SEND_TIP:
return ModalSendTip; return ModalSendTip;
case MODALS.MEMBERSHIP_SPLASH:
return OdyseeMembershipSplash;
case MODALS.SOCIAL_SHARE: case MODALS.SOCIAL_SHARE:
return ModalSocialShare; return ModalSocialShare;
case MODALS.PUBLISH: case MODALS.PUBLISH:
@ -180,6 +188,8 @@ function getModal(id) {
return ModalDeleteCollection; return ModalDeleteCollection;
case MODALS.CONFIRM_REMOVE_CARD: case MODALS.CONFIRM_REMOVE_CARD:
return ModalRemoveCard; return ModalRemoveCard;
case MODALS.CONFIRM_ODYSEE_MEMBERSHIP:
return ModalConfirmOdyseeMembership;
case MODALS.CONFIRM_REMOVE_COMMENT: case MODALS.CONFIRM_REMOVE_COMMENT:
return ModalRemoveComment; return ModalRemoveComment;
default: default:

View file

@ -237,7 +237,14 @@ function ChannelPage(props: Props) {
{cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />} {cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />}
{cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />} {cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />}
<div className="channel__primary-info"> <div className="channel__primary-info">
<ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs /> <ChannelThumbnail
className="channel__thumbnail--channel-page"
uri={uri}
allowGifs
showMemberBadge
isChannel
hideStakedIndicator
/>
<h1 className="channel__title"> <h1 className="channel__title">
<TruncatedText lines={2} showTooltip> <TruncatedText lines={2} showTooltip>
{title || (channelName && '@' + channelName)} {title || (channelName && '@' + channelName)}

View file

@ -4,7 +4,9 @@ import {
selectFetchingMyChannels, selectFetchingMyChannels,
makeSelectClaimIsPending, makeSelectClaimIsPending,
selectPendingIds, selectPendingIds,
selectClaimsByUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import { doFetchChannelListMine } from 'redux/actions/claims'; import { doFetchChannelListMine } from 'redux/actions/claims';
import { doSetActiveChannel } from 'redux/actions/app'; import { doSetActiveChannel } from 'redux/actions/app';
import { selectYoutubeChannels } from 'redux/selectors/user'; import { selectYoutubeChannels } from 'redux/selectors/user';
@ -30,12 +32,14 @@ const select = (state) => {
fetchingChannels: selectFetchingMyChannels(state), fetchingChannels: selectFetchingMyChannels(state),
youtubeChannels: selectYoutubeChannels(state), youtubeChannels: selectYoutubeChannels(state),
pendingChannels, pendingChannels,
claimsByUri: selectClaimsByUri(state),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({
fetchChannelListMine: () => dispatch(doFetchChannelListMine()), fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
doSetActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)), doSetActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
doFetchUserMemberships: (claimIds) => dispatch(doFetchUserMemberships(claimIds)),
}); });
export default connect(select, perform)(ChannelsPage); export default connect(select, perform)(ChannelsPage);

View file

@ -12,6 +12,7 @@ import LbcSymbol from 'component/common/lbc-symbol';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import HelpLink from 'component/common/help-link'; import HelpLink from 'component/common/help-link';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import useGetUserMemberships from 'effects/use-get-user-memberships';
type Props = { type Props = {
channelUrls: Array<string>, channelUrls: Array<string>,
@ -20,6 +21,8 @@ type Props = {
youtubeChannels: ?Array<any>, youtubeChannels: ?Array<any>,
doSetActiveChannel: (string) => void, doSetActiveChannel: (string) => void,
pendingChannels: Array<string>, pendingChannels: Array<string>,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function ChannelsPage(props: Props) { export default function ChannelsPage(props: Props) {
@ -30,10 +33,15 @@ export default function ChannelsPage(props: Props) {
youtubeChannels, youtubeChannels,
doSetActiveChannel, doSetActiveChannel,
pendingChannels, pendingChannels,
claimsByUri,
doFetchUserMemberships,
} = props; } = props;
const [rewardData, setRewardData] = React.useState(); const [rewardData, setRewardData] = React.useState();
const hasYoutubeChannels = youtubeChannels && Boolean(youtubeChannels.length); const hasYoutubeChannels = youtubeChannels && Boolean(youtubeChannels.length);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, channelUrls, claimsByUri, doFetchUserMemberships);
const { push } = useHistory(); const { push } = useHistory();
useEffect(() => { useEffect(() => {
@ -51,6 +59,7 @@ export default function ChannelsPage(props: Props) {
{channelUrls && Boolean(channelUrls.length) && ( {channelUrls && Boolean(channelUrls.length) && (
<ClaimList <ClaimList
showMemberBadge
header={<h1 className="section__title">{__('Your channels')}</h1>} header={<h1 className="section__title">{__('Your channels')}</h1>}
headerAltControls={ headerAltControls={
<Button <Button

View file

@ -5,7 +5,7 @@ import { makeSelectClaimForUri } from 'redux/selectors/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams } from 'redux/selectors/livestream'; import { selectActiveLivestreams } from 'redux/selectors/livestream';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { doToggleTagFollowDesktop } from 'redux/actions/tags'; import { doToggleTagFollowDesktop } from 'redux/actions/tags';
import { selectClientSetting, selectLanguage } from 'redux/selectors/settings'; import { selectClientSetting, selectLanguage } from 'redux/selectors/settings';
@ -20,9 +20,9 @@ const select = (state, props) => {
followedTags: selectFollowedTags(state), followedTags: selectFollowedTags(state),
repostedUri: repostedUri, repostedUri: repostedUri,
repostedClaim: repostedUri ? makeSelectClaimForUri(repostedUri)(state) : null, repostedClaim: repostedUri ? makeSelectClaimForUri(repostedUri)(state) : null,
isAuthenticated: selectUserVerifiedEmail(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT), tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
activeLivestreams: selectActiveLivestreams(state), activeLivestreams: selectActiveLivestreams(state),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
languageSetting: selectLanguage(state), languageSetting: selectLanguage(state),
searchInLanguage: selectClientSetting(state, SETTINGS.SEARCH_IN_LANGUAGE), searchInLanguage: selectClientSetting(state, SETTINGS.SEARCH_IN_LANGUAGE),
}; };

View file

@ -28,10 +28,10 @@ type Props = {
searchInLanguage: boolean, searchInLanguage: boolean,
doToggleTagFollowDesktop: (string) => void, doToggleTagFollowDesktop: (string) => void,
doResolveUri: (string) => void, doResolveUri: (string) => void,
isAuthenticated: boolean,
tileLayout: boolean, tileLayout: boolean,
activeLivestreams: ?LivestreamInfo, activeLivestreams: ?LivestreamInfo,
doFetchActiveLivestreams: (orderBy: ?Array<string>, lang: ?Array<string>) => void, doFetchActiveLivestreams: (orderBy: ?Array<string>, lang: ?Array<string>) => void,
userHasPremiumPlus: boolean,
}; };
function DiscoverPage(props: Props) { function DiscoverPage(props: Props) {
@ -44,11 +44,11 @@ function DiscoverPage(props: Props) {
searchInLanguage, searchInLanguage,
doToggleTagFollowDesktop, doToggleTagFollowDesktop,
doResolveUri, doResolveUri,
isAuthenticated,
tileLayout, tileLayout,
activeLivestreams, activeLivestreams,
doFetchActiveLivestreams, doFetchActiveLivestreams,
dynamicRouteProps, dynamicRouteProps,
userHasPremiumPlus,
} = props; } = props;
const buttonRef = useRef(); const buttonRef = useRef();
@ -191,7 +191,7 @@ function DiscoverPage(props: Props) {
hiddenNsfwMessage={<HiddenNsfw type="page" />} hiddenNsfwMessage={<HiddenNsfw type="page" />}
repostedClaimId={repostedClaim ? repostedClaim.claim_id : null} repostedClaimId={repostedClaim ? repostedClaim.claim_id : null}
injectedItem={ injectedItem={
SHOW_ADS && !isAuthenticated && !isWildWest && { node: <Ads small type="video" tileLayout={tileLayout} /> } SHOW_ADS && !userHasPremiumPlus && !isWildWest && { node: <Ads small type="video" tileLayout={tileLayout} /> }
} }
// Assume wild west page if no dynamicRouteProps // Assume wild west page if no dynamicRouteProps
// Not a very good solution, but just doing it for now // Not a very good solution, but just doing it for now

View file

@ -3,7 +3,7 @@ import * as SETTINGS from 'constants/settings';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream'; import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectSubscriptions } from 'redux/selectors/subscriptions';
import { selectShowMatureContent, selectHomepageData, selectClientSetting } from 'redux/selectors/settings'; import { selectShowMatureContent, selectHomepageData, selectClientSetting } from 'redux/selectors/settings';
@ -18,6 +18,7 @@ const select = (state) => ({
activeLivestreams: selectActiveLivestreams(state), activeLivestreams: selectActiveLivestreams(state),
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state), fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS), hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}); });
const perform = (dispatch) => ({ const perform = (dispatch) => ({

View file

@ -31,6 +31,7 @@ type Props = {
doFetchActiveLivestreams: () => void, doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean, fetchingActiveLivestreams: boolean,
hideScheduledLivestreams: boolean, hideScheduledLivestreams: boolean,
userHasPremiumPlus: boolean,
}; };
function HomePage(props: Props) { function HomePage(props: Props) {
@ -44,6 +45,7 @@ function HomePage(props: Props) {
doFetchActiveLivestreams, doFetchActiveLivestreams,
fetchingActiveLivestreams, fetchingActiveLivestreams,
hideScheduledLivestreams, hideScheduledLivestreams,
userHasPremiumPlus,
} = props; } = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0; const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0; const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
@ -100,7 +102,9 @@ function HomePage(props: Props) {
prefixUris={getLivestreamUris(activeLivestreams, options.channelIds)} prefixUris={getLivestreamUris(activeLivestreams, options.channelIds)}
pinUrls={pinUrls} pinUrls={pinUrls}
injectedItem={ injectedItem={
index === 0 && SHOW_ADS && !authenticated && { node: <Ads small type="video" tileLayout />, replace: true } index === 0 &&
SHOW_ADS &&
!userHasPremiumPlus && { node: <Ads small type="video" tileLayout />, replace: true }
} }
/> />
); );

View file

@ -1,8 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectHasChannels, selectFetchingMyChannels } from 'redux/selectors/claims'; import { selectHasChannels, selectFetchingMyChannels } from 'redux/selectors/claims';
import { doClearPublish } from 'redux/actions/publish'; import { doClearPublish } from 'redux/actions/publish';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim, selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { doFetchNoSourceClaims } from 'redux/actions/livestream'; import { doFetchNoSourceClaims } from 'redux/actions/livestream';
import { selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { import {
makeSelectPendingLivestreamsForChannelId, makeSelectPendingLivestreamsForChannelId,
makeSelectLivestreamsForChannelId, makeSelectLivestreamsForChannelId,
@ -23,6 +24,9 @@ const select = (state) => {
myLivestreamClaims: makeSelectLivestreamsForChannelId(channelId)(state), myLivestreamClaims: makeSelectLivestreamsForChannelId(channelId)(state),
pendingClaims: makeSelectPendingLivestreamsForChannelId(channelId)(state), pendingClaims: makeSelectPendingLivestreamsForChannelId(channelId)(state),
fetchingLivestreams: makeSelectIsFetchingLivestreams(channelId)(state), fetchingLivestreams: makeSelectIsFetchingLivestreams(channelId)(state),
user: selectUser(state),
odyseeMembership: selectOdyseeMembershipName(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({

View file

@ -17,6 +17,7 @@ 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 { LIVESTREAM_RTMP_URL } from 'constants/livestream'; import { LIVESTREAM_RTMP_URL } from 'constants/livestream';
import { ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from '../../../config';
type Props = { type Props = {
hasChannels: boolean, hasChannels: boolean,
@ -29,6 +30,9 @@ type Props = {
fetchingLivestreams: boolean, fetchingLivestreams: boolean,
channelId: ?string, channelId: ?string,
channelName: ?string, channelName: ?string,
user: ?User,
activeChannelStakedLevel: number,
odyseeMembership: string,
}; };
export default function LivestreamSetupPage(props: Props) { export default function LivestreamSetupPage(props: Props) {
@ -44,6 +48,9 @@ export default function LivestreamSetupPage(props: Props) {
fetchingLivestreams, fetchingLivestreams,
channelId, channelId,
channelName, channelName,
user,
odyseeMembership,
activeChannelStakedLevel,
} = props; } = props;
const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined }); const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined });
@ -51,6 +58,22 @@ export default function LivestreamSetupPage(props: Props) {
const hasLivestreamClaims = Boolean(myLivestreamClaims.length || pendingClaims.length); const hasLivestreamClaims = Boolean(myLivestreamClaims.length || pendingClaims.length);
const hasEnoughLBCToStream = activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM;
const { odysee_live_disabled: liveDisabled, odysee_live_enabled: liveEnabled } = user || {};
const livestreamEnabled = Boolean(
ENABLE_NO_SOURCE_CLAIMS && user && !liveDisabled && (liveEnabled || odyseeMembership || hasEnoughLBCToStream)
);
let reasonAllowedToStream = '';
if (odyseeMembership) {
reasonAllowedToStream = 'you purchased Odysee Premium';
} else if (liveEnabled) {
reasonAllowedToStream = 'your livestreaming was turned on manually';
} else if (hasEnoughLBCToStream) {
reasonAllowedToStream = 'you have enough staked LBC';
}
function createStreamKey() { function createStreamKey() {
if (!channelId || !channelName || !sigData.signature || !sigData.signing_ts) return null; if (!channelId || !channelName || !sigData.signature || !sigData.signing_ts) return null;
return `${channelId}?d=${toHex(channelName)}&s=${sigData.signature}&t=${sigData.signing_ts}`; return `${channelId}?d=${toHex(channelName)}&s=${sigData.signature}&t=${sigData.signing_ts}`;
@ -169,201 +192,231 @@ export default function LivestreamSetupPage(props: Props) {
return ( return (
<Page> <Page>
{fetchingChannels && ( {/* no livestreaming privs because no premium membership */}
<div className="main--empty"> {!livestreamEnabled && !odyseeMembership && (
<Spinner delayed /> <div>
<h2 className={''}>Join Odysee Premium to be able to livestream</h2>
<Button
button="primary"
label={__('Join Odysee Premium')}
icon={ICONS.FINANCE}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}`}
className="membership_button"
/>
</div> </div>
)} )}
{!fetchingChannels && !hasChannels && ( {/* show livestreaming frontend */}
<Yrbl {livestreamEnabled && (
type="happy" <div className="card-stack">
title={__("You haven't created a channel yet, let's fix that!")} {/* getting channel data */}
actions={ {fetchingChannels && (
<div className="section__actions"> <div className="main--empty">
<Button button="primary" navigate={`/$/${PAGES.CHANNEL_NEW}`} label={__('Create A Channel')} /> <Spinner delayed />
</div> </div>
} )}
/>
)}
{!fetchingChannels && (
<>
<div className="section__actions--between">
<ChannelSelector hideAnon />
</div>
</>
)}
{fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && ( {/* no channels yet */}
<div className="main--empty"> {!fetchingChannels && !hasChannels && (
<Spinner delayed /> <Yrbl
</div> type="happy"
)} title={__("You haven't created a channel yet, let's fix that!")}
<div className="card-stack"> actions={
{!fetchingChannels && channelId && ( <div className="section__actions">
<> <Button button="primary" navigate={`/$/${PAGES.CHANNEL_NEW}`} label={__('Create A Channel')} />
<Card </div>
titleActions={
<Button button="close" icon={showHelp ? ICONS.UP : ICONS.DOWN} onClick={() => setShowHelp(!showHelp)} />
} }
title={__('Go Live on Odysee')}
subtitle={<>{__(`You're invited to try out our new livestreaming service while in beta!`)} </>}
actions={showHelp && helpText}
/> />
{streamKey && totalLivestreamClaims.length > 0 && ( )}
<Card
className="section"
title={__('Your stream key')}
actions={
<>
<CopyableText
primaryButton
name="stream-server"
label={__('Stream server')}
copyable={LIVESTREAM_RTMP_URL}
snackMessage={__('Copied stream server URL.')}
/>
<CopyableText
primaryButton
enableInputMask
name="livestream-key"
label={__('Stream key (can be reused)')}
copyable={streamKey}
snackMessage={__('Copied stream key.')}
/>
</>
}
/>
)}
{totalLivestreamClaims.length > 0 ? ( {/* channel selector */}
<> {!fetchingChannels && (
{Boolean(pendingClaims.length) && ( <>
<div className="section"> <div className="section__actions--between">
<ClaimList <ChannelSelector hideAnon />
header={__('Your pending livestreams uploads')} </div>
uris={pendingClaims.map((claim) => claim.permanent_url)} </>
/> )}
</div>
)} {/* getting livestreams */}
{Boolean(myLivestreamClaims.length) && ( {fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
<> <div className="main--empty">
{Boolean(upcomingStreams.length) && ( <Spinner delayed />
<div className="section"> </div>
<ClaimList )}
header={<ListHeader title={__('Your Scheduled Livestreams')} />}
uris={upcomingStreams.map((claim) => claim.permanent_url)} {!fetchingChannels && channelId && (
/> <>
</div> <Card
)} titleActions={
<Button
button="close"
icon={showHelp ? ICONS.UP : ICONS.DOWN}
onClick={() => setShowHelp(!showHelp)}
/>
}
title={__('Go Live on Odysee')}
subtitle={
<>{__(`Congratulations, you have access to livestreaming because ${reasonAllowedToStream}!`)} </>
}
actions={showHelp && helpText}
/>
{streamKey && totalLivestreamClaims.length > 0 && (
<Card
className="section"
title={__('Your stream key')}
actions={
<>
<CopyableText
primaryButton
name="stream-server"
label={__('Stream server')}
copyable={LIVESTREAM_RTMP_URL}
snackMessage={__('Copied stream server URL.')}
/>
<CopyableText
primaryButton
enableInputMask
name="livestream-key"
label={__('Stream key (can be reused)')}
copyable={streamKey}
snackMessage={__('Copied stream key.')}
/>
</>
}
/>
)}
{totalLivestreamClaims.length > 0 ? (
<>
{Boolean(pendingClaims.length) && (
<div className="section"> <div className="section">
<ClaimList <ClaimList
header={ header={__('Your pending livestreams uploads')}
<ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} /> uris={pendingClaims.map((claim) => claim.permanent_url)}
}
empty={
<I18nMessage
tokens={{
check_again: (
<Button
button="link"
onClick={() => fetchNoSourceClaims(channelId)}
label={__('Check again')}
/>
),
}}
>
Nothing here yet. %check_again%
</I18nMessage>
}
uris={pastStreams.map((claim) => claim.permanent_url)}
/> />
</div> </div>
</> )}
)} {Boolean(myLivestreamClaims.length) && (
</> <>
) : ( {Boolean(upcomingStreams.length) && (
<Yrbl <div className="section">
className="livestream__publish-intro" <ClaimList
title={__('No livestream publishes found')} header={<ListHeader title={__('Your Scheduled Livestreams')} />}
subtitle={__( uris={upcomingStreams.map((claim) => claim.permanent_url)}
'You need to upload your livestream details before you can go live. Please note: Replays must be published manually after your stream via the Update button on the livestream.' />
)} </div>
actions={ )}
<div className="section__actions"> <div className="section">
<Button <ClaimList
button="primary" header={
onClick={() => <ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} />
doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`) }
} empty={
label={__('Create A Livestream')} <I18nMessage
/> tokens={{
<Button check_again: (
button="alt" <Button
onClick={() => { button="link"
fetchNoSourceClaims(channelId); onClick={() => fetchNoSourceClaims(channelId)}
}} label={__('Check again')}
label={__('Check again...')} />
/> ),
</div> }}
} >
/> Nothing here yet. %check_again%
)} </I18nMessage>
}
{/* Debug Stuff */} uris={pastStreams.map((claim) => claim.permanent_url)}
{streamKey && false && activeChannelClaim && ( />
<div style={{ marginTop: 'var(--spacing-l)' }}> </div>
<h3>Debug Info</h3> </>
)}
{/* Channel ID */} </>
<FormField ) : (
name={'channelId'} <Yrbl
label={'Channel ID'} className="livestream__publish-intro"
type={'text'} title={__('No livestream publishes found')}
defaultValue={activeChannelClaim.claim_id} subtitle={__(
readOnly 'You need to upload your livestream details before you can go live. Please note: Replays must be published manually after your stream via the Update button on the livestream.'
)}
actions={
<div className="section__actions">
<Button
button="primary"
onClick={() =>
doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`)
}
label={__('Create A Livestream')}
/>
<Button
button="alt"
onClick={() => {
fetchNoSourceClaims(channelId);
}}
label={__('Check again...')}
/>
</div>
}
/> />
)}
{/* Signature */} {/* Debug Stuff */}
<FormField {streamKey && false && activeChannelClaim && (
name={'signature'} <div style={{ marginTop: 'var(--spacing-l)' }}>
label={'Signature'} <h3>Debug Info</h3>
type={'text'}
defaultValue={sigData.signature}
readOnly
/>
{/* Signature TS */} {/* Channel ID */}
<FormField <FormField
name={'signaturets'} name={'channelId'}
label={'Signature Timestamp'} label={'Channel ID'}
type={'text'} type={'text'}
defaultValue={sigData.signing_ts} defaultValue={activeChannelClaim.claim_id}
readOnly readOnly
/> />
{/* Hex Data */} {/* Signature */}
<FormField <FormField
name={'datahex'} name={'signature'}
label={'Hex Data'} label={'Signature'}
type={'text'} type={'text'}
defaultValue={toHex(activeChannelClaim.name)} defaultValue={sigData.signature}
readOnly readOnly
/> />
{/* Channel Public Key */} {/* Signature TS */}
<FormField <FormField
name={'channelpublickey'} name={'signaturets'}
label={'Public Key'} label={'Signature Timestamp'}
type={'text'} type={'text'}
defaultValue={activeChannelClaim.value.public_key} defaultValue={sigData.signing_ts}
readOnly readOnly
/> />
</div>
)} {/* Hex Data */}
</> <FormField
)} name={'datahex'}
</div> label={'Hex Data'}
type={'text'}
defaultValue={toHex(activeChannelClaim.name)}
readOnly
/>
{/* Channel Public Key */}
<FormField
name={'channelpublickey'}
label={'Public Key'}
type={'text'}
defaultValue={activeChannelClaim.value.public_key}
readOnly
/>
</div>
)}
</>
)}
</div>
)}
</Page> </Page>
); );
} }

View file

@ -0,0 +1,27 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import OdyseeMembership from './view';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { selectMyChannelClaims, selectClaimsByUri } from 'redux/selectors/claims';
import { doFetchUserMemberships, doCheckUserOdyseeMemberships } from 'redux/actions/user';
import { selectUser } from 'redux/selectors/user';
const select = (state) => {
const activeChannelClaim = selectActiveChannelClaim(state);
return {
activeChannelClaim,
channels: selectMyChannelClaims(state),
claimsByUri: selectClaimsByUri(state),
incognito: selectIncognito(state),
user: selectUser(state),
};
};
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
fetchUserMemberships: (claimIds) => dispatch(doFetchUserMemberships(claimIds)),
updateUserOdyseeMembershipStatus: (user) => dispatch(doCheckUserOdyseeMemberships(user)),
});
export default connect(select, perform)(OdyseeMembership);

View file

@ -0,0 +1,646 @@
/* eslint-disable no-console */
// @flow
import React from 'react';
import moment from 'moment';
import Page from 'component/page';
import Spinner from 'component/spinner';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import Card from 'component/common/card';
import MembershipSplash from 'component/membershipSplash';
import Button from 'component/button';
import ChannelSelector from 'component/channelSelector';
import PremiumBadge from 'component/common/premium-badge';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import usePersistedState from 'effects/use-persisted-state';
let stripeEnvironment = getStripeEnvironment();
const isDev = process.env.NODE_ENV !== 'production';
// odysee channel information since the memberships are only for Odysee
const odyseeChannelId = '80d2590ad04e36fb1d077a9b9e3a8bba76defdf8';
const odyseeChannelName = '@odysee';
type Props = {
history: { action: string, push: (string) => void, replace: (string) => void },
location: { search: string, pathname: string },
totalBalance: ?number,
openModal: (string, {}) => void,
activeChannelClaim: ?ChannelClaim,
channels: ?Array<ChannelClaim>,
claimsByUri: { [string]: any },
fetchUserMemberships: (claimIdCsv: string) => void,
incognito: boolean,
updateUserOdyseeMembershipStatus: () => void,
user: ?User,
};
const OdyseeMembershipPage = (props: Props) => {
const {
openModal,
activeChannelClaim,
channels,
claimsByUri,
fetchUserMemberships,
updateUserOdyseeMembershipStatus,
incognito,
user,
} = props;
const shouldUseEuro = localStorage.getItem('gdprRequired');
let currencyToUse;
if (shouldUseEuro === 'true') {
currencyToUse = 'eur';
} else {
currencyToUse = 'usd';
}
const userChannelName = activeChannelClaim ? activeChannelClaim.name : '';
const userChannelClaimId = activeChannelClaim && activeChannelClaim.claim_id;
const [cardSaved, setCardSaved] = React.useState();
const [membershipOptions, setMembershipOptions] = React.useState();
const [userMemberships, setUserMemberships] = React.useState();
const [canceledMemberships, setCanceledMemberships] = React.useState();
const [activeMemberships, setActiveMemberships] = React.useState();
const [purchasedMemberships, setPurchasedMemberships] = React.useState([]);
const [hasShownModal, setHasShownModal] = React.useState(false);
const [shouldFetchUserMemberships, setFetchUserMemberships] = React.useState(true);
const [showHelp, setShowHelp] = usePersistedState('premium-help-seen', true);
const hasMembership = activeMemberships && activeMemberships.length > 0;
const channelUrls = channels && channels.map((channel) => channel.permanent_url);
// check if membership data for user is already fetched, if it's needed then fetch it
useGetUserMemberships(shouldFetchUserMemberships, channelUrls, claimsByUri, (value) => {
fetchUserMemberships(value);
setFetchUserMemberships(false);
});
async function populateMembershipData() {
try {
// show the memberships the user is subscribed to
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let activeMemberships = [];
let canceledMemberships = [];
let purchasedMemberships = [];
for (const membership of response) {
// if it's autorenewing it's considered 'active'
const isActive = membership.Membership.auto_renew;
if (isActive) {
activeMemberships.push(membership);
} else {
canceledMemberships.push(membership);
}
purchasedMemberships.push(membership.Membership.membership_id);
}
// hide the other membership options if there's already a purchased membership
if (activeMemberships.length > 0) {
setMembershipOptions(false);
}
setActiveMemberships(activeMemberships);
setCanceledMemberships(canceledMemberships);
setPurchasedMemberships(purchasedMemberships);
// update the state to show the badge
fetchUserMemberships(userChannelClaimId || '');
setUserMemberships(response);
} catch (err) {
console.log(err);
}
setFetchUserMemberships(false);
}
React.useEffect(() => {
if (!shouldFetchUserMemberships) setFetchUserMemberships(true);
}, [shouldFetchUserMemberships]);
React.useEffect(function () {
(async function () {
try {
// check if there is a payment method
const response = await Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
);
// hardcoded to first card
const hasAPaymentCard = Boolean(response && response.PaymentMethods && response.PaymentMethods[0]);
setCardSaved(hasAPaymentCard);
} catch (err) {
const customerDoesntExistError = 'user as customer is not setup yet';
if (err.message === customerDoesntExistError) {
setCardSaved(false);
} else {
console.log(err);
}
}
try {
// check the available membership for odysee.com
const response = await Lbryio.call(
'membership',
'list',
{
environment: stripeEnvironment,
channel_id: odyseeChannelId,
channel_name: odyseeChannelName,
},
'post'
);
// hide other options if there's already a membership
if (activeMemberships && activeMemberships.length > 0) {
setMembershipOptions(false);
} else {
setMembershipOptions(response);
}
} catch (err) {
console.log(err);
}
populateMembershipData();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stillWaitingFromBackend =
purchasedMemberships === undefined ||
cardSaved === undefined ||
membershipOptions === undefined ||
userMemberships === undefined;
const formatDate = function (date) {
return moment(new Date(date)).format('MMMM DD YYYY');
};
const deleteData = async function () {
await Lbryio.call('membership', 'clear', {}, 'post');
// $FlowFixMe
location.reload();
};
// dont pass channel name and id when calling purchase
const noChannelsOrIncognitoMode = incognito || !channels;
// TODO: can clean this up, some repeating text
function buildPurchaseString(price, interval, plan) {
let featureString = '';
// generate different strings depending on other conditions
if (plan === 'Premium' && !noChannelsOrIncognitoMode) {
featureString =
'Your badge will be shown for your ' +
userChannelName +
' channel in all areas of the app, and can be added to two additional channels in the future for free. ';
} else if (plan === 'Premium+' && !noChannelsOrIncognitoMode) {
featureString =
'The no ads feature applies site-wide for all channels and your badge will be shown for your ' +
userChannelName +
' channel in all areas of the app, and can be added to two additional channels in the future for free. ';
} else if (plan === 'Premium' && !channels) {
featureString =
'You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium+' && !channels) {
featureString =
'The no ads feature applies site-wide. You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium' && incognito) {
featureString =
'You currently have no channel selected and will not have a badge be visible, if you want to show a badge you can select a channel now, ' +
'or you can show a badge for up to three channels in the future for free.';
} else if (plan === 'Premium+' && incognito) {
featureString =
'The no ads feature applies site-wide. You currently have no channel selected and will not have a badge be visible, ' +
'if you want to show a badge you can select a channel now, or you can show a badge for up to three channels in the future for free.';
}
let purchaseString =
`You are purchasing a ${interval}ly membership, that is active immediately ` +
`and will renew ${interval}ly at a price of ${currencyToUse.toUpperCase()} ${
currencyToUse === 'usd' ? '$' : '€'
}${price / 100}. ` +
featureString +
'You can cancel Premium at any time (no refunds) and you can also close this window and choose a different membership option.';
return __(purchaseString);
}
const purchaseMembership = function (e, membershipOption, price) {
e.preventDefault();
e.stopPropagation();
const planName = membershipOption.Membership.name;
const membershipId = e.currentTarget.getAttribute('membership-id');
const priceId = e.currentTarget.getAttribute('price-id');
const purchaseString = buildPurchaseString(price.unit_amount, price.recurring.interval, planName);
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
userChannelClaimId: noChannelsOrIncognitoMode ? undefined : userChannelClaimId,
userChannelName: noChannelsOrIncognitoMode ? undefined : userChannelName,
priceId,
purchaseString,
plan: planName,
populateMembershipData,
setMembershipOptions,
updateUserOdyseeMembershipStatus,
user,
});
};
const cancelMembership = async function (e, membership) {
const membershipId = e.currentTarget.getAttribute('membership-id');
const cancellationString =
'You are cancelling your Odysee Premium. You will still have access to all the paid ' +
'features until the point of the expiration of your current membership, at which point you will not be charged ' +
'again and your membership will no longer be active. At this time, there is no way to subscribe to another membership if you cancel and there are no refunds.';
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
hasMembership,
purchaseString: cancellationString,
populateMembershipData,
});
};
function convertPriceToString(price) {
const interval = price.recurring.interval;
if (interval === 'year') {
return 'Yearly';
} else if (interval === 'month') {
return 'Monthly';
}
}
function capitalizeWord(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function buildCurrencyDisplay(priceObject) {
let currencySymbol;
if (priceObject.currency === 'eur') {
currencySymbol = '€';
} else if (priceObject.currency === 'usd') {
currencySymbol = '$';
}
const currency = priceObject.currency.toUpperCase();
return currency + ' ' + currencySymbol;
}
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
const { interval, plan } = params;
const planValue = params.plan;
// description to be shown under plan name
function getPlanDescription(plan) {
if (plan === 'Premium') {
return 'Badge on profile, exclusive and early access to features';
// if there's more plans added this needs to be expanded
} else {
return 'All Premium features, and no ads';
}
}
// add a bit of a delay otherwise it's a bit jarring
const timeoutValue = 300;
// if user already selected plan, wait a bit (so it's not jarring) and open modal
React.useEffect(() => {
if (!stillWaitingFromBackend && planValue && cardSaved) {
setTimeout(function () {
// clear query params
window.history.replaceState(null, null, window.location.pathname);
setHasShownModal(true);
// open confirm purchase
// $FlowFixMe
document.querySelector('[plan="' + plan + '"][interval="' + interval + '"]').click();
}, timeoutValue);
}
}, [stillWaitingFromBackend, planValue, cardSaved]);
const helpText = (
<div className="section__subtitle">
<p>
{__(
`First of all, thank you for considering or purchasing a membership, it means a ton to us! A few important details to know: `
)}
</p>
<p>
<ul>
<li>
{__(
`Early access and exclusive features include: livestreaming and the ability to post odysee hyperlinks and images in comments + blogs. More to come later.`
)}
<li>
{__(
`The yearly Premium+ membership has a discount compared to monthly, and Premium is only available yearly.`
)}
</li>
<li>{__(`These are limited time rates, so get in early!`)}</li>
</li>
<li>
{__(
`There may be higher tiers available in the future for creators and anyone else who wants to support us.`
)}
</li>
<li>
{__(`Badges will be displayed on a single channel to start, with an option to add on two more later on.`)}
</li>
<li>
{__(`Cannot upgrade or downgrade a membership at this time. Refunds are not available. Choose wisely.`)}
</li>
</ul>
</p>
</div>
);
return (
<>
<Page className="premium-wrapper">
{/** splash frontend **/}
{!stillWaitingFromBackend && purchasedMemberships.length === 0 && !planValue && !hasShownModal ? (
<MembershipSplash pageLocation={'confirmPage'} currencyToUse={currencyToUse} />
) : (
/** odysee membership page **/
<div className={'card-stack'}>
{!stillWaitingFromBackend && cardSaved !== false && (
<>
<h1 style={{ fontSize: '23px' }}>{__('Odysee Premium')}</h1>
{/* let user switch channel */}
<div style={{ marginTop: '10px' }}>
<ChannelSelector uri={activeChannelClaim && activeChannelClaim.permanent_url} />
{/* explainer help text */}
<Card
titleActions={
<Button
button="close"
icon={showHelp ? ICONS.UP : ICONS.DOWN}
onClick={() => setShowHelp(!showHelp)}
/>
}
title={__('Get More Information')}
subtitle={<>{__(`Expand to learn more about how Odysee Premium works`)} </>}
actions={showHelp && helpText}
className={'premium-explanation-text'}
/>
</div>
</>
)}
{/** available memberships **/}
{/* if they have a card and don't have a membership yet */}
{!stillWaitingFromBackend && membershipOptions && purchasedMemberships.length < 1 && cardSaved !== false && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Available Memberships')}</h2>
</div>
<Card>
{membershipOptions.map((membershipOption, i) => (
<>
<div key={i}>
{purchasedMemberships && !purchasedMemberships.includes(membershipOption.Membership.id) && (
<>
<div className="premium-option">
<h4 className="membership_title">
{membershipOption.Membership.name}
<PremiumBadge membership={membershipOption.Membership.name} />
</h4>
{/* plan description */}
<h4 className="membership_subtitle">
{getPlanDescription(membershipOption.Membership.name)}
</h4>
<>
{membershipOption.Prices.map((price) => (
<>
{/* dont show a monthly Premium membership option */}
{!(
price.recurring.interval === 'month' &&
membershipOption.Membership.name === 'Premium'
) && (
<>
{price.currency === currencyToUse && (
<div>
<h4 className="membership_info">
<b>Interval:</b> {convertPriceToString(price)}
</h4>
<h4 className="membership_info">
<b>Price:</b> {buildCurrencyDisplay(price)}
{price.unit_amount / 100}/{capitalizeWord(price.recurring.interval)}
</h4>
<Button
button="primary"
onClick={(e) => purchaseMembership(e, membershipOption, price)}
membership-id={membershipOption.Membership.id}
membership-subscription-period={membershipOption.Membership.type}
price-id={price.id}
className="membership_button"
label={__('Join via ' + price.recurring.interval + 'ly membership')}
icon={ICONS.FINANCE}
interval={price.recurring.interval}
plan={membershipOption.Membership.name}
/>
</div>
)}
</>
)}
</>
))}
</>
</div>
</>
)}
</div>
</>
))}
</Card>
</>
)}
{!stillWaitingFromBackend && cardSaved === true && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Your Active Memberships')}</h2>
</div>
<Card>
{/** * list of active memberships from user ***/}
<div>
{/* <h1 style={{ fontSize: '19px' }}>Active Memberships</h1> */}
{!stillWaitingFromBackend && activeMemberships && activeMemberships.length === 0 && (
<>
<h4>{__('You currently have no active memberships')}</h4>
</>
)}
{/** active memberships **/}
{!stillWaitingFromBackend &&
activeMemberships &&
activeMemberships.map((membership) => (
<>
<div className="premium-option">
{/* membership name */}
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
{/* description section */}
<h4 className="membership_subtitle">
{getPlanDescription(membership.MembershipDetails.name)}
</h4>
<h4 className="membership_info">
<b>{__('Registered On:')}</b> {formatDate(membership.Membership.created_at)}
</h4>
<h4 className="membership_info">
<b>{__('Auto-Renews On')}:</b>{' '}
{formatDate(membership.Subscription.current_period_end * 1000)}
</h4>
{!stillWaitingFromBackend && membership.type === 'yearly' && (
<>
<h4 className="membership_info">
<b>{__('Membership Period Options:')}</b> {__('Yearly')}
</h4>
<h4 className="membership_info">
${(membership.cost_usd * 12) / 100} {__('USD For A One Year Membership')} ($
{membership.cost_usd / 100} {__('Per Month')})
</h4>
</>
)}
<Button
button="alt"
membership-id={membership.Membership.membership_id}
onClick={(e) => cancelMembership(e, membership)}
className="cancel-membership-button"
label={__('Cancel membership')}
icon={ICONS.FINANCE}
/>
</div>
</>
))}
</div>
</Card>
<>
{/** canceled memberships **/}
<div className="card__title-section">
<h2 className="card__title">{__('Canceled Memberships')}</h2>
</div>
<Card>
{canceledMemberships && canceledMemberships.length === 0 && (
<>
<h4>{__('You currently have no canceled memberships')}</h4>
</>
)}
{canceledMemberships &&
canceledMemberships.map((membership) => (
<>
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
<div className="premium-option">
<h4 className="membership_info">
<b>{__('Registered On:')}</b> {formatDate(membership.Membership.created_at)}
</h4>
<h4 className="membership_info">
<b>{__('Canceled On:')}</b> {formatDate(membership.Subscription.canceled_at * 1000)}
</h4>
<h4 className="membership_info">
<b>{__('Still Valid Until:')}</b> {formatDate(membership.Membership.expires)}
</h4>
</div>
</>
))}
</Card>
</>
</>
)}
{/** send user to add card if they don't have one yet */}
{!stillWaitingFromBackend && cardSaved === false && (
<div>
<br />
<h2 className={'getPaymentCard'}>
{__(
'Please save a card as a payment method so you can join Odysee Premium. After the card is added, click Back.'
)}
</h2>
<Button
button="primary"
label={__('Add A Card')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
className="membership_button"
/>
</div>
)}
{/** loading section **/}
{stillWaitingFromBackend && (
<div className="main--empty">
<Spinner />
</div>
)}
{/** clear membership data (only available on dev) **/}
{isDev && cardSaved && purchasedMemberships.length > 0 && (
<>
<h1 style={{ marginTop: '30px', fontSize: '20px' }}>
{__('Clear Membership Data (Only Available On Dev)')}
</h1>
<div>
<Button
button="primary"
label={__('Clear Membership Data')}
icon={ICONS.SETTINGS}
className="membership_button"
onClick={deleteData}
/>
</div>
</>
)}
</div>
)}
</Page>
</>
);
};
export default OdyseeMembershipPage;

View file

@ -22,7 +22,7 @@ import {
} from 'redux/selectors/file_info'; } from 'redux/selectors/file_info';
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => { claims: any, file: FileState, content: any }; type GetState = () => { claims: any, file: FileState, content: any, user: User };
export function doOpenFileInFolder(path: string) { export function doOpenFileInFolder(path: string) {
return () => { return () => {
shell.showItemInFolder(path); shell.showItemInFolder(path);

View file

@ -13,7 +13,7 @@ import { SEARCH_SERVER_API, SEARCH_SERVER_API_ALT } from 'config';
import { SEARCH_OPTIONS } from 'constants/search'; import { SEARCH_OPTIONS } from 'constants/search';
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => { claims: any, search: SearchState }; type GetState = () => { claims: any, search: SearchState, user: User };
type SearchOptions = { type SearchOptions = {
size?: number, size?: number,

View file

@ -3,6 +3,7 @@ import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { doFetchChannelListMine } from 'redux/actions/claims'; import { doFetchChannelListMine } from 'redux/actions/claims';
import { isURIValid, normalizeURI } from 'util/lbryURI'; import { isURIValid, normalizeURI } from 'util/lbryURI';
import { batchActions } from 'util/batch-actions'; import { batchActions } from 'util/batch-actions';
import { getStripeEnvironment } from 'util/stripe';
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { doClaimRewardType, doRewardList } from 'redux/actions/rewards'; import { doClaimRewardType, doRewardList } from 'redux/actions/rewards';
@ -16,6 +17,9 @@ const AUTH_IN_PROGRESS = 'authInProgress';
export let sessionStorageAvailable = false; export let sessionStorageAvailable = false;
const CHECK_INTERVAL = 200; const CHECK_INTERVAL = 200;
const AUTH_WAIT_TIMEOUT = 10000; const AUTH_WAIT_TIMEOUT = 10000;
const stripeEnvironment = getStripeEnvironment();
const ODYSEE_CHANNEL_ID = '80d2590ad04e36fb1d077a9b9e3a8bba76defdf8';
export function doFetchInviteStatus(shouldCallRewardList = true) { export function doFetchInviteStatus(shouldCallRewardList = true) {
return (dispatch) => { return (dispatch) => {
@ -101,6 +105,59 @@ function checkAuthBusy() {
}); });
} }
/***
* Given a user, return their highest ranking Odysee membership (Premium or Premium Plus)
* @param dispatch
* @param user
* @returns {Promise<void>}
*/
export function doCheckUserOdyseeMemberships(user) {
return async (dispatch) => {
// get memberships for a given user
// TODO: in the future, can we specify this just to @odysee?
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let savedMemberships = [];
let highestMembershipRanking;
// TODO: this will work for now, but it should be adjusted
// TODO: to check if it's active, or if it's cancelled if it's still valid past current date
// loop through all memberships and save the @odysee ones
// maybe in the future we can only hit @odysee in the API call
for (const membership of response) {
if (membership.MembershipDetails && membership.MembershipDetails.channel_name === '@odysee') {
savedMemberships.push(membership.MembershipDetails.name);
}
}
// determine highest ranking membership based on returned data
// note: this is from an odd state in the API where a user can be both premium/Premium + at the same time
// I expect this can change once upgrade/downgrade is implemented
if (savedMemberships.length > 0) {
// if premium plus is a membership, return that, otherwise it's only premium
const premiumPlusExists = savedMemberships.includes('Premium+');
if (premiumPlusExists) {
highestMembershipRanking = 'Premium+';
} else {
highestMembershipRanking = 'Premium';
}
}
dispatch({
type: ACTIONS.ADD_ODYSEE_MEMBERSHIP_DATA,
data: { user, odyseeMembershipName: highestMembershipRanking },
});
};
}
// TODO: Call doInstallNew separately so we don't have to pass appVersion and os_system params? // TODO: Call doInstallNew separately so we don't have to pass appVersion and os_system params?
export function doAuthenticate( export function doAuthenticate(
appVersion, appVersion,
@ -124,6 +181,11 @@ export function doAuthenticate(
data: { user, accessToken: token }, data: { user, accessToken: token },
}); });
// if user is an Odysee member, get the membership details
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
if (shareUsageData) { if (shareUsageData) {
dispatch(doRewardList()); dispatch(doRewardList());
@ -153,6 +215,11 @@ export function doUserFetch() {
Lbryio.getCurrentUser() Lbryio.getCurrentUser()
.then((user) => { .then((user) => {
// get user membership status
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
dispatch({ dispatch({
type: ACTIONS.USER_FETCH_SUCCESS, type: ACTIONS.USER_FETCH_SUCCESS,
data: { user }, data: { user },
@ -174,6 +241,11 @@ export function doUserCheckEmailVerified() {
return (dispatch) => { return (dispatch) => {
Lbryio.getCurrentUser().then((user) => { Lbryio.getCurrentUser().then((user) => {
if (user.has_verified_email) { if (user.has_verified_email) {
// check premium membership
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
dispatch(doRewardList()); dispatch(doRewardList());
dispatch({ dispatch({
@ -347,16 +419,19 @@ export function doUserCheckIfEmailExists(email) {
Lbryio.call('user', 'exists', { email }, 'post') Lbryio.call('user', 'exists', { email }, 'post')
.catch((error) => { .catch((error) => {
// no email
if (error.response && error.response.status === 404) { if (error.response && error.response.status === 404) {
dispatch({ dispatch({
type: ACTIONS.USER_EMAIL_NEW_DOES_NOT_EXIST, type: ACTIONS.USER_EMAIL_NEW_DOES_NOT_EXIST,
}); });
// sign in by email
} else if (error.response && error.response.status === 412) { } else if (error.response && error.response.status === 412) {
triggerEmailFlow(false); triggerEmailFlow(false);
} }
throw error; throw error;
}) })
// sign the user in
.then(success, failure); .then(success, failure);
}; };
} }
@ -784,3 +859,40 @@ export function doCheckYoutubeTransfer() {
}); });
}; };
} }
/***
* Receives a csv of channel claim ids, hits the backend and returns nicely formatted object with relevant info
* @param claimIdCsv
* @returns {(function(*): Promise<void>)|*}
*/
export function doFetchUserMemberships(claimIdCsv) {
return async (dispatch) => {
// check if users have odysee memberships (premium/premium+)
const response = await Lbryio.call('membership', 'check', {
channel_id: ODYSEE_CHANNEL_ID,
claim_ids: claimIdCsv,
});
let updatedResponse = {};
// loop through returned users
for (const user in response) {
// if array was returned for a user (indicating a membership exists), otherwise is null
if (response[user] && response[user].length) {
// get membership for user
// note: a for loop is kind of odd, indicates there may be multiple memberships?
// probably not needed depending on what we do with the frontend, should revisit
for (const membership of response[user]) {
if (membership.channel_name) {
updatedResponse[user] = membership.name;
}
}
} else {
// note the user has been fetched but is null
updatedResponse[user] = null;
}
}
dispatch({ type: ACTIONS.ADD_CLAIMIDS_MEMBERSHIP_DATA, data: { response: updatedResponse } });
};
}

View file

@ -363,6 +363,28 @@ reducers[ACTIONS.USER_PASSWORD_SET_FAILURE] = (state, action) =>
passwordSetError: action.data.error, passwordSetError: action.data.error,
}); });
reducers[ACTIONS.ADD_ODYSEE_MEMBERSHIP_DATA] = (state, action) => {
return Object.assign({}, state, {
odyseeMembershipName: action.data.odyseeMembershipName,
});
};
reducers[ACTIONS.ADD_CLAIMIDS_MEMBERSHIP_DATA] = (state, action) => {
let latestData = {};
// add additional user membership value
if (state.odyseeMembershipsPerClaimIds) {
latestData = Object.assign({}, state.odyseeMembershipsPerClaimIds, action.data.response);
} else {
// otherwise just send the current data because nothing is saved yet
latestData = action.data.response;
}
return Object.assign({}, state, {
odyseeMembershipsPerClaimIds: latestData,
});
};
export default function userReducer(state = defaultState, action) { export default function userReducer(state = defaultState, action) {
const handler = reducers[action.type]; const handler = reducers[action.type];
if (handler) return handler(state, action); if (handler) return handler(state, action);

View file

@ -9,7 +9,7 @@ import { isClaimNsfw, filterClaims, getChannelIdFromClaim, isStreamPlaceholderCl
import * as CLAIM from 'constants/claim'; import * as CLAIM from 'constants/claim';
import { INTERNAL_TAGS } from 'constants/tags'; import { INTERNAL_TAGS } from 'constants/tags';
type State = { claims: any }; type State = { claims: any, user: User };
const selectState = (state: State) => state.claims || {}; const selectState = (state: State) => state.claims || {};
@ -791,3 +791,41 @@ export const selectIsMyChannelCountOverLimit = createSelector(
return false; return false;
} }
); );
/**
* Given a uri of a channel, check if there an Odysee membership value
* @param state
* @param uri
* @returns {*}
*/
export const selectOdyseeMembershipForUri = function (state: State, uri: string) {
const claim = selectClaimForUri(state, uri);
const uploaderChannelClaimId = getChannelIdFromClaim(claim);
// looks for the uploader id
if (uploaderChannelClaimId) {
const matchingMembershipOfUser =
state.user &&
state.user.odyseeMembershipsPerClaimIds &&
state.user.odyseeMembershipsPerClaimIds[uploaderChannelClaimId];
return matchingMembershipOfUser;
}
return undefined;
};
/**
* Given a uri of a channel, check if there an Odysee membership value
* @param state
* @param channelId
* @returns {*}
*/
export const selectOdyseeMembershipForChannelId = function (state: State, channelId: string) {
// looks for the uploader id
const matchingMembershipOfUser =
state.user && state.user.odyseeMembershipsPerClaimIds && state.user.odyseeMembershipsPerClaimIds[channelId];
return matchingMembershipOfUser;
};

View file

@ -16,7 +16,7 @@ import { isClaimNsfw, getChannelFromClaim } from 'util/claim';
import { selectSubscriptionUris } from 'redux/selectors/subscriptions'; import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
import { getCommentsListTitle } from 'util/comments'; import { getCommentsListTitle } from 'util/comments';
type State = { claims: any, comments: CommentsState }; type State = { claims: any, comments: CommentsState, user: User };
const selectState = (state) => state.comments || {}; const selectState = (state) => state.comments || {};

View file

@ -18,7 +18,7 @@ import { FORCE_CONTENT_TYPE_PLAYER, FORCE_CONTENT_TYPE_COMIC } from 'constants/c
const RECENT_HISTORY_AMOUNT = 10; const RECENT_HISTORY_AMOUNT = 10;
const HISTORY_ITEMS_PER_PAGE = 50; const HISTORY_ITEMS_PER_PAGE = 50;
type State = { claims: any, content: any }; type State = { claims: any, content: any, user: User };
export const selectState = (state: State) => state.content || {}; export const selectState = (state: State) => state.content || {};

View file

@ -20,7 +20,7 @@ import { selectHistory } from 'redux/selectors/content';
import { selectAllCostInfoByUri } from 'lbryinc'; import { selectAllCostInfoByUri } from 'lbryinc';
import { SIMPLE_SITE } from 'config'; import { SIMPLE_SITE } from 'config';
type State = { claims: any, search: SearchState }; type State = { claims: any, search: SearchState, user: User };
export const selectState = (state: State): SearchState => state.search; export const selectState = (state: State): SearchState => state.search;

View file

@ -104,6 +104,14 @@ export const selectYouTubeImportError = (state) => selectState(state).youtubeCha
export const selectSetReferrerPending = (state) => selectState(state).referrerSetIsPending; export const selectSetReferrerPending = (state) => selectState(state).referrerSetIsPending;
export const selectSetReferrerError = (state) => selectState(state).referrerSetError; export const selectSetReferrerError = (state) => selectState(state).referrerSetError;
export const selectOdyseeMembershipName = (state) => selectState(state).odyseeMembershipName;
export const selectOdyseeMembershipIsPremiumPlus = (state) => {
const odyseeMembershipName = selectState(state).odyseeMembershipName;
if (!odyseeMembershipName) return undefined;
return selectState(state).odyseeMembershipName === 'Premium+';
};
export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => { export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => {
const total = state.youtubeChannelImportTotal; const total = state.youtubeChannelImportTotal;
const complete = state.youtubeChannelImportComplete || 0; const complete = state.youtubeChannelImportComplete || 0;

View file

@ -36,6 +36,7 @@
@import 'component/markdown-editor'; @import 'component/markdown-editor';
@import 'component/markdown-preview'; @import 'component/markdown-preview';
@import 'component/media'; @import 'component/media';
@import 'component/membership';
@import 'component/menu-button'; @import 'component/menu-button';
@import 'component/modal'; @import 'component/modal';
@import 'component/nag'; @import 'component/nag';

View file

@ -285,12 +285,27 @@ a.button--alt {
max-width: 100%; max-width: 100%;
text-align: left; text-align: left;
.comment__badge {
padding-right: 0px;
svg {
height: 1.2rem;
width: 1.2rem;
}
}
.channel-name { .channel-name {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
display: flex;
align-items: flex-end;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
color: rgba(var(--color-text-base), 0.6); color: rgba(var(--color-text-base), 0.6);
.icon {
margin-left: var(--spacing-xxs);
}
} }
.markdown-preview & { .markdown-preview & {

View file

@ -329,9 +329,10 @@
padding-top: 0; padding-top: 0;
} }
} }
@media (min-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
// padding: var(--spacing-s); .button--close {
// padding-right: 0px; top: calc(var(--spacing-m) * -1);
}
} }
} }

View file

@ -199,6 +199,37 @@ $actions-z-index: 2;
width: 5rem; width: 5rem;
background-size: cover; background-size: cover;
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
.button {
display: flex;
position: absolute;
text-align: center;
width: 80%;
bottom: -16%;
left: unset !important;
background-color: unset !important;
right: -1rem;
}
.link--small {
right: -3rem !important;
}
.comment__badge {
position: absolute;
text-align: center;
width: 80%;
left: 22%;
bottom: -30%;
svg {
stroke: unset;
overflow: visible;
width: 100%;
height: 100%;
}
}
} }
.channel-thumbnail--resolving { .channel-thumbnail--resolving {
@ -237,6 +268,31 @@ $actions-z-index: 2;
top: 0; top: 0;
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
} }
.comment__badge {
position: absolute;
text-align: center;
width: 60%;
left: 0;
svg {
stroke: unset;
overflow: visible;
width: 100%;
height: 100%;
}
}
}
.claim-preview .channel-thumbnail .comment__badge {
padding: 0px;
left: 12%;
bottom: -20%;
svg {
width: 3rem;
height: 3rem;
}
} }
.channel-thumbnail__custom { .channel-thumbnail__custom {
@ -332,6 +388,15 @@ $actions-z-index: 2;
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
padding-top: var(--spacing-xl); padding-top: var(--spacing-xl);
} }
.comment__badge {
padding-right: 0px;
svg {
height: 2.5rem;
width: 2.5rem;
}
}
} }
.channel__meta { .channel__meta {
@ -501,6 +566,15 @@ $actions-z-index: 2;
padding: var(--spacing-s); padding: var(--spacing-s);
overflow: hidden; overflow: hidden;
.comment__badge {
padding-right: 0px;
svg {
height: 2rem;
width: 2rem;
}
}
.channel-thumbnail { .channel-thumbnail {
height: 2rem; height: 2rem;
width: 2rem; width: 2rem;
@ -634,6 +708,19 @@ $actions-z-index: 2;
} }
} }
.icon__wrapper--PremiumPlusBadge {
margin-left: 3px;
height: 22px !important;
width: 22px !important;
padding: 0 !important;
display: inline-block !important;
margin-bottom: -9px !important;
}
.icon__wrapper--PremiumPlusBadge > svg {
margin-top: -2px;
}
.channelsPage-wrapper { .channelsPage-wrapper {
.claim-preview__wrapper--channel { .claim-preview__wrapper--channel {
position: relative; position: relative;

View file

@ -8,16 +8,12 @@
} }
} }
.comment__badge--globalMod { // fix contrast on hover of channel selector, couldn't come up with a better way
.st0 { div[role='menuitem'] .channel__list-item .comment__badge svg {
// @see: ICONS.BADGE_MOD stroke: unset !important;
fill: #fe7500;
}
} }
.comment__badge--mod { // icon is a bit bright and loud
.st0 { .icon--PremiumPlus {
// @see: ICONS.BADGE_MOD filter: brightness(0.92);
fill: #ff3850;
}
} }

View file

@ -228,6 +228,11 @@ $thumbnailWidthSmall: 2rem;
color: rgba(var(--color-secondary-dynamic), 1); color: rgba(var(--color-secondary-dynamic), 1);
} }
} }
.comment__badge svg {
height: 1.4rem;
width: 1.4rem;
}
} }
.comment__pin { .comment__pin {

View file

@ -779,6 +779,17 @@ $recent-msg-button__height: 2rem;
color: var(--color-black); color: var(--color-black);
opacity: 0.9; opacity: 0.9;
} }
.channel-name {
max-width: unset;
p {
max-width: 5rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
} }
.livestreamSuperchat__info--notSticker { .livestreamSuperchat__info--notSticker {

View file

@ -86,6 +86,11 @@
.livestreamComment__info { .livestreamComment__info {
overflow: hidden; overflow: hidden;
.comment__badge svg {
height: 1.4rem;
width: 1.4rem;
}
} }
.livestream__comment--superchat { .livestream__comment--superchat {

View file

@ -0,0 +1,68 @@
.membership_title {
margin-top: var(--spacing-l);
font-size: 18px;
font-weight: var(--font-weight-bold);
.comment__badge {
margin-left: var(--spacing-s);
svg {
height: 2rem;
width: 2rem;
}
}
}
.membership_title:first-of-type {
margin-top: 0;
}
.membership_subtitle {
font-size: var(--font-small);
color: var(--color-text-subtitle);
margin-bottom: var(--spacing-s);
}
.membership_info {
font-size: var(--font-small);
}
.membership_button {
display: block;
margin-bottom: 18px;
margin-top: 10px;
}
.cancel-membership-button {
display: block;
margin-bottom: 18px;
margin-top: 13px;
}
.explanation-text {
margin-bottom: 16px;
}
.premium-explanation-text {
.icon--ChevronDown,
.icon--ChevronUp {
height: 24px;
width: 24px;
}
.card__main-actions {
margin-bottom: 15px;
}
.button--close {
margin-top: 3px;
left: 223px;
width: 34px;
}
.section__subtitle {
margin-bottom: 25px;
}
margin-bottom: 21px;
}

View file

@ -49,3 +49,353 @@
.doodle { .doodle {
position: fixed; position: fixed;
} }
// Membership Splash
.membership-splash {
padding-left: 10rem;
padding-right: 10rem;
display: flex;
flex-flow: column wrap;
justify-content: space-around;
transition: padding 0.6s;
::selection {
background-color: black;
color: white;
}
@media (min-width: $breakpoint-xlarge) {
padding-left: 0rem;
padding-right: 0rem;
margin-left: -16rem;
margin-right: -16rem;
}
@media (max-width: $breakpoint-medium) {
padding-left: 4rem;
padding-right: 4rem;
}
@media (max-width: $breakpoint-small) {
padding-left: unset;
padding-right: unset;
}
.membership-splash__banner {
display: flex;
flex-basis: 100%;
flex: auto;
align-items: center;
background: #283263;
margin-bottom: var(--spacing-xxs);
img {
display: flex;
width: 50%;
flex-basis: 50%;
}
@media (max-width: $breakpoint-small) {
flex-flow: column-reverse;
img {
width: 100%;
flex-basis: 100%;
}
}
}
.membership-splash__title {
display: inline-block;
width: 100%;
flex-basis: 50%;
padding: var(--spacing-l);
padding-top: 0;
padding-bottom: 0;
color: #fff;
// font-size: 2.6rem;
font-size: 2.3vw;
line-height: 2.8vw;
font-weight: 100;
b {
font-weight: 900;
}
section {
&:first-of-type {
margin-bottom: 2rem;
img {
width: 96%;
}
}
}
@media (max-width: $breakpoint-small) {
font-size: 2rem;
line-height: 2.1rem;
margin-bottom: 1.6rem;
section {
&:first-of-type {
img {
margin-top: 1.6rem;
}
}
}
}
}
.membership-splash__info-wrapper {
display: flex;
flex-basis: 100%;
.membership-splash__info {
position: relative;
background: #fff;
flex-basis: 33%;
color: #000;
padding-bottom: 4.2rem;
.membership-splash__info-content {
padding-left: var(--spacing-m);
}
}
@media (max-width: $breakpoint-small) {
flex-flow: column;
.membership-splash__info {
flex-basis: 100%;
margin-bottom: var(--spacing-xxs);
}
// "creating a revolutionary platform.." copy
.membership-splash__info:nth-of-type(1) {
line-height: 1.5rem;
}
}
.membership-splash__info:nth-child(1) {
// padding: var(--spacing-l);
padding: 3%;
font-size: 1vw;
@media (min-width: $breakpoint-xlarge) {
font-size: 1vw;
}
@media (max-width: $breakpoint-medium) {
font-size: 1.3vw;
}
@media (max-width: $breakpoint-small) {
font-size: 0.9rem;
}
}
.membership-splash__info:nth-child(2) {
margin-left: var(--spacing-xxs);
margin-right: var(--spacing-xxs);
.membership-splash__info-header {
background-color: #d5cee5;
color: #626092;
}
@media (max-width: $breakpoint-small) {
margin-left: 0;
margin-right: 0;
}
}
.membership-splash__info:nth-child(3) {
.membership-splash__info-header {
background-color: #ffd976;
color: #c95b16;
}
}
}
.membership-splash__info-header {
margin-bottom: 18px;
.membership-splash__info-price {
display: flex;
align-items: center;
font-size: 2.6rem;
font-size: 2.6vw;
font-weight: 900;
img {
display: inline-block;
width: 36%;
//margin-left: 1rem;
margin-left: 7%;
//margin-right: 1.4rem;
margin-right: 7%;
}
section {
display: inline-block;
}
.membership-splash__info-range {
font-size: 1rem;
margin-top: -10px;
}
@media (min-width: $breakpoint-xlarge) {
img {
//margin-left: 2%;
width: 33%;
}
.membership-splash__info-range {
font-size: 1.8rem;
margin-top: -1.6rem;
}
}
@media (max-width: $breakpoint-medium) {
img {
//margin-left: 2%;
width: 30%;
}
}
@media (max-width: $breakpoint-small) {
font-size: 2.2rem;
img {
margin-left: 1.4rem;
margin-right: 1.4rem;
width: 5rem;
}
}
}
}
.membership-splash__info-content {
display: flex;
align-items: center;
font-size: 1vw;
font-weight: 900;
margin-top: 8px;
.icon {
width: 2rem;
margin-right: var(--spacing-xs);
}
@media (max-width: $breakpoint-medium) {
font-size: 1.3vw;
}
@media (max-width: $breakpoint-small) {
font-size: 0.9rem;
}
}
.membership-splash__info-button {
text-align: center;
position: absolute;
width: calc(100% - (var(--spacing-m) * 2));
bottom: 0;
margin-left: var(--spacing-m);
margin-right: var(--spacing-m);
display: inline-block;
margin-bottom: var(--spacing-m);
.button--primary {
display: inline-block;
border: 2px solid #debca0;
border-radius: 20px;
padding: 8px 20px 8px 20px;
background-color: unset !important;
text-align: center;
.button__label {
align-self: center;
display: inline-block;
color: #debca0;
text-transform: uppercase;
font-size: 17px;
line-height: 2rem;
font-weight: var(--font-weight-bold);
}
&:hover {
background-color: unset;
}
@media (min-width: $breakpoint-xlarge) {
padding: 18px 40px 18px 40px;
height: 4.8rem;
border-radius: 2.4rem;
margin-bottom: 3%;
.button__label {
line-height: 4rem;
}
}
@media (max-width: $breakpoint-small) {
padding: 8px 10px 8px 10px;
box-sizing: border-box;
.button__label {
font-size: 1.2rem;
}
}
}
}
}
.modal {
@media (min-width: $breakpoint-small) {
.membership-splash {
padding-left: unset;
padding-right: unset;
.membership-splash__banner {
.membership-splash__title {
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
font-size: 1.6rem;
line-height: 1.7rem;
section:first-of-type {
margin-bottom: var(--spacing-m);
}
}
}
.membership-splash__info-wrapper {
.membership-splash__info {
.membership-splash__info-header {
.membership-splash__info-price {
img {
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
}
section {
font-size: 1.6rem;
}
.membership-splash__info-range {
font-size: 0.8rem;
margin-top: -10px;
}
}
}
.membership-splash__info-button {
margin-left: var(--spacing-xs);
.button {
padding: 4px 6px 4px 6px;
.button__content {
.button__label {
font-size: 0.74rem;
}
}
}
}
.membership-splash__info-content {
padding-left: var(--spacing-xxs);
font-size: 0.7rem;
.icon {
width: 1.7rem;
height: 1.7rem;
margin-right: var(--spacing-xxs);
}
}
}
.membership-splash__info:nth-child(1) {
padding: var(--spacing-m);
font-size: 0.7rem;
}
}
}
}
@media (max-width: $breakpoint-small) {
.membership-splash {
.membership-splash__banner {
.membership-splash__title {
img {
margin-top: var(--spacing-l);
}
}
}
}
}
}
.stripe__confirm-remove-membership {
.card__subtitle {
line-height: 39px;
margin-top: 5px;
margin-bottom: -1px;
}
}

View file

@ -43,3 +43,8 @@
color: var(--color-text) !important; color: var(--color-text) !important;
} }
} }
.profile-badge__tooltip {
margin-top: 0px !important;
right: 0.5rem !important;
}

View file

@ -1177,6 +1177,22 @@ img {
} }
} }
.premium-wrapper {
.membership_title {
.comment__badge {
.icon {
margin-bottom: -6px;
}
}
}
.premium-option {
background-color: rgba(var(--color-header-background-base), 0.4);
border-radius: var(--border-radius);
padding: var(--spacing-m);
margin-bottom: var(--spacing-m);
}
}
// Temporary master classes // Temporary master classes
.date_time { .date_time {
font-size: var(--font-xsmall); font-size: var(--font-xsmall);

View file

@ -1,14 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectTheme } from 'redux/selectors/settings'; import { selectTheme } from 'redux/selectors/settings';
import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims'; import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import Ads from './view'; import Ads from './view';
const select = (state, props) => ({ const select = (state, props) => ({
theme: selectTheme(state), theme: selectTheme(state),
claim: makeSelectClaimForUri(props.uri)(state), claim: makeSelectClaimForUri(props.uri)(state),
isMature: selectClaimIsNsfwForUri(state, props.uri), isMature: selectClaimIsNsfwForUri(state, props.uri),
authenticated: selectUserVerifiedEmail(state), userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}); });
export default connect(select)(Ads); export default connect(select)(Ads);

View file

@ -40,7 +40,8 @@ type Props = {
small: boolean, small: boolean,
claim: Claim, claim: Claim,
isMature: boolean, isMature: boolean,
authenticated: boolean, triggerBlacklist: boolean,
userHasPremiumPlus: boolean,
className?: string, className?: string,
}; };
@ -64,16 +65,9 @@ function clearAdElements() {
} }
function Ads(props: Props) { function Ads(props: Props) {
const { const { type = 'video', tileLayout, small, userHasPremiumPlus, className } = props;
location: { pathname },
type = 'video',
tileLayout,
small,
authenticated,
className,
} = props;
const shouldShowAds = SHOW_ADS && !authenticated; const shouldShowAds = SHOW_ADS && !userHasPremiumPlus;
const mobileAds = IS_ANDROID || IS_IOS; const mobileAds = IS_ANDROID || IS_IOS;
// this is populated from app based on location // this is populated from app based on location
@ -106,8 +100,8 @@ function Ads(props: Props) {
log_in_to_domain: ( log_in_to_domain: (
<Button <Button
button="link" button="link"
label={__('Log in to %domain%', { domain: DOMAIN })} label={__('Get Odysee Premium+', { domain: DOMAIN })}
navigate={`/$/${PAGES.AUTH}?redirect=${pathname}`} navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}`}
/> />
), ),
}} }}